@tejasanik/postgres-mcp-server 2.1.0 → 2.2.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.
Files changed (96) hide show
  1. package/README.md +186 -10
  2. package/dist/db-manager/index.d.ts +7 -0
  3. package/dist/db-manager/index.d.ts.map +1 -0
  4. package/dist/db-manager/index.js +7 -0
  5. package/dist/db-manager/index.js.map +1 -0
  6. package/dist/db-manager/validation.d.ts +35 -0
  7. package/dist/db-manager/validation.d.ts.map +1 -0
  8. package/dist/db-manager/validation.js +54 -0
  9. package/dist/db-manager/validation.js.map +1 -0
  10. package/dist/db-manager.d.ts +175 -5
  11. package/dist/db-manager.d.ts.map +1 -1
  12. package/dist/db-manager.js +589 -26
  13. package/dist/db-manager.js.map +1 -1
  14. package/dist/index.js +141 -11
  15. package/dist/index.js.map +1 -1
  16. package/dist/tools/analysis-tools.d.ts.map +1 -1
  17. package/dist/tools/analysis-tools.js +53 -49
  18. package/dist/tools/analysis-tools.js.map +1 -1
  19. package/dist/tools/schema-tools.d.ts +40 -1
  20. package/dist/tools/schema-tools.d.ts.map +1 -1
  21. package/dist/tools/schema-tools.js +174 -92
  22. package/dist/tools/schema-tools.js.map +1 -1
  23. package/dist/tools/server-tools.d.ts +1 -0
  24. package/dist/tools/server-tools.d.ts.map +1 -1
  25. package/dist/tools/server-tools.js +10 -6
  26. package/dist/tools/server-tools.js.map +1 -1
  27. package/dist/tools/sql/utils/connection-utils.d.ts +79 -0
  28. package/dist/tools/sql/utils/connection-utils.d.ts.map +1 -0
  29. package/dist/tools/sql/utils/connection-utils.js +129 -0
  30. package/dist/tools/sql/utils/connection-utils.js.map +1 -0
  31. package/dist/tools/sql/utils/constants.d.ts +55 -0
  32. package/dist/tools/sql/utils/constants.d.ts.map +1 -0
  33. package/dist/tools/sql/utils/constants.js +55 -0
  34. package/dist/tools/sql/utils/constants.js.map +1 -0
  35. package/dist/tools/sql/utils/dry-run-utils.d.ts +31 -0
  36. package/dist/tools/sql/utils/dry-run-utils.d.ts.map +1 -0
  37. package/dist/tools/sql/utils/dry-run-utils.js +173 -0
  38. package/dist/tools/sql/utils/dry-run-utils.js.map +1 -0
  39. package/dist/tools/sql/utils/file-handler.d.ts +57 -0
  40. package/dist/tools/sql/utils/file-handler.d.ts.map +1 -0
  41. package/dist/tools/sql/utils/file-handler.js +150 -0
  42. package/dist/tools/sql/utils/file-handler.js.map +1 -0
  43. package/dist/tools/sql/utils/index.d.ts +12 -0
  44. package/dist/tools/sql/utils/index.d.ts.map +1 -0
  45. package/dist/tools/sql/utils/index.js +12 -0
  46. package/dist/tools/sql/utils/index.js.map +1 -0
  47. package/dist/tools/sql/utils/result-formatter.d.ts +94 -0
  48. package/dist/tools/sql/utils/result-formatter.d.ts.map +1 -0
  49. package/dist/tools/sql/utils/result-formatter.js +154 -0
  50. package/dist/tools/sql/utils/result-formatter.js.map +1 -0
  51. package/dist/tools/sql/utils/sql-parser.d.ts +125 -0
  52. package/dist/tools/sql/utils/sql-parser.d.ts.map +1 -0
  53. package/dist/tools/sql/utils/sql-parser.js +468 -0
  54. package/dist/tools/sql/utils/sql-parser.js.map +1 -0
  55. package/dist/tools/sql-tools.d.ts +21 -0
  56. package/dist/tools/sql-tools.d.ts.map +1 -1
  57. package/dist/tools/sql-tools.js +383 -532
  58. package/dist/tools/sql-tools.js.map +1 -1
  59. package/dist/types.d.ts +38 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/utils/retry.d.ts +1 -1
  62. package/dist/utils/retry.d.ts.map +1 -1
  63. package/dist/utils/retry.js.map +1 -1
  64. package/dist/utils/validation.d.ts +45 -9
  65. package/dist/utils/validation.d.ts.map +1 -1
  66. package/dist/utils/validation.js +335 -72
  67. package/dist/utils/validation.js.map +1 -1
  68. package/package.json +9 -2
  69. package/dist/__tests__/analysis-tools.test.d.ts +0 -2
  70. package/dist/__tests__/analysis-tools.test.d.ts.map +0 -1
  71. package/dist/__tests__/analysis-tools.test.js +0 -294
  72. package/dist/__tests__/analysis-tools.test.js.map +0 -1
  73. package/dist/__tests__/db-manager.test.d.ts +0 -2
  74. package/dist/__tests__/db-manager.test.d.ts.map +0 -1
  75. package/dist/__tests__/db-manager.test.js +0 -410
  76. package/dist/__tests__/db-manager.test.js.map +0 -1
  77. package/dist/__tests__/mcp-server.test.d.ts +0 -13
  78. package/dist/__tests__/mcp-server.test.d.ts.map +0 -1
  79. package/dist/__tests__/mcp-server.test.js +0 -146
  80. package/dist/__tests__/mcp-server.test.js.map +0 -1
  81. package/dist/__tests__/schema-tools.test.d.ts +0 -2
  82. package/dist/__tests__/schema-tools.test.d.ts.map +0 -1
  83. package/dist/__tests__/schema-tools.test.js +0 -171
  84. package/dist/__tests__/schema-tools.test.js.map +0 -1
  85. package/dist/__tests__/server-tools.test.d.ts +0 -2
  86. package/dist/__tests__/server-tools.test.d.ts.map +0 -1
  87. package/dist/__tests__/server-tools.test.js +0 -113
  88. package/dist/__tests__/server-tools.test.js.map +0 -1
  89. package/dist/__tests__/sql-tools.test.d.ts +0 -2
  90. package/dist/__tests__/sql-tools.test.d.ts.map +0 -1
  91. package/dist/__tests__/sql-tools.test.js +0 -1912
  92. package/dist/__tests__/sql-tools.test.js.map +0 -1
  93. package/dist/__tests__/validation.test.d.ts +0 -2
  94. package/dist/__tests__/validation.test.d.ts.map +0 -1
  95. package/dist/__tests__/validation.test.js +0 -203
  96. package/dist/__tests__/validation.test.js.map +0 -1
@@ -1,1912 +0,0 @@
1
- import { jest, describe, it, expect, beforeEach, beforeAll } from '@jest/globals';
2
- import * as fs from 'fs';
3
- // Use jest.unstable_mockModule for ESM
4
- const mockQuery = jest.fn();
5
- const mockGetClient = jest.fn();
6
- const mockIsConnected = jest.fn();
7
- const mockBeginTransaction = jest.fn();
8
- const mockCommitTransaction = jest.fn();
9
- const mockRollbackTransaction = jest.fn();
10
- const mockQueryInTransaction = jest.fn();
11
- const mockGetConnectionContext = jest.fn();
12
- jest.unstable_mockModule('../db-manager.js', () => ({
13
- getDbManager: jest.fn(() => ({
14
- query: mockQuery,
15
- getClient: mockGetClient,
16
- isConnected: mockIsConnected.mockReturnValue(true),
17
- beginTransaction: mockBeginTransaction,
18
- commitTransaction: mockCommitTransaction,
19
- rollbackTransaction: mockRollbackTransaction,
20
- queryInTransaction: mockQueryInTransaction,
21
- getConnectionContext: mockGetConnectionContext.mockReturnValue({
22
- server: 'test-server',
23
- database: 'test-db',
24
- schema: 'public'
25
- }),
26
- })),
27
- resetDbManager: jest.fn(),
28
- }));
29
- // Dynamic import after mock
30
- let executeSql;
31
- let explainQuery;
32
- let executeSqlFile;
33
- let mutationPreview;
34
- let batchExecute;
35
- let beginTransaction;
36
- let commitTransaction;
37
- let rollbackTransaction;
38
- let getConnectionContext;
39
- beforeAll(async () => {
40
- const module = await import('../tools/sql-tools.js');
41
- executeSql = module.executeSql;
42
- explainQuery = module.explainQuery;
43
- executeSqlFile = module.executeSqlFile;
44
- mutationPreview = module.mutationPreview;
45
- batchExecute = module.batchExecute;
46
- beginTransaction = module.beginTransaction;
47
- commitTransaction = module.commitTransaction;
48
- rollbackTransaction = module.rollbackTransaction;
49
- getConnectionContext = module.getConnectionContext;
50
- });
51
- describe('SQL Tools', () => {
52
- beforeEach(() => {
53
- jest.clearAllMocks();
54
- // Reset mock implementations to avoid leakage between tests
55
- mockQuery.mockReset();
56
- mockIsConnected.mockReturnValue(true);
57
- });
58
- describe('executeSql', () => {
59
- it('should require sql parameter', async () => {
60
- await expect(executeSql({ sql: '' }))
61
- .rejects.toThrow('sql parameter cannot be empty');
62
- await expect(executeSql({ sql: null }))
63
- .rejects.toThrow('sql parameter is required');
64
- });
65
- it('should reject SQL that is too long', async () => {
66
- const longSql = 'SELECT ' + 'a'.repeat(100001);
67
- await expect(executeSql({ sql: longSql }))
68
- .rejects.toThrow('exceeds 100000 characters');
69
- });
70
- it('should allow large scripts with allowLargeScript=true', async () => {
71
- const longSql = 'SELECT ' + 'a'.repeat(100001);
72
- mockQuery.mockResolvedValue({
73
- rows: [],
74
- fields: []
75
- });
76
- // Should not throw with allowLargeScript=true
77
- const result = await executeSql({ sql: longSql, allowLargeScript: true });
78
- expect(result).toBeDefined();
79
- });
80
- it('should return results for small result sets', async () => {
81
- mockQuery.mockResolvedValue({
82
- rows: [{ id: 1, name: 'Test' }],
83
- fields: [{ name: 'id' }, { name: 'name' }]
84
- });
85
- const result = await executeSql({ sql: 'SELECT * FROM users' });
86
- expect(result.rows).toHaveLength(1);
87
- expect(result.rowCount).toBe(1);
88
- expect(result.fields).toEqual(['id', 'name']);
89
- expect(result.outputFile).toBeUndefined();
90
- expect(result.truncated).toBeUndefined();
91
- expect(result.executionTimeMs).toBeDefined();
92
- expect(result.offset).toBe(0);
93
- expect(result.hasMore).toBe(false);
94
- });
95
- it('should support pagination with offset and maxRows', async () => {
96
- const rows = Array.from({ length: 100 }, (_, i) => ({ id: i }));
97
- mockQuery.mockResolvedValue({
98
- rows,
99
- fields: [{ name: 'id' }]
100
- });
101
- // Get first page
102
- const result1 = await executeSql({ sql: 'SELECT * FROM users', maxRows: 10, offset: 0 });
103
- expect(result1.rows).toHaveLength(10);
104
- expect(result1.rows[0].id).toBe(0);
105
- expect(result1.offset).toBe(0);
106
- expect(result1.hasMore).toBe(true);
107
- expect(result1.rowCount).toBe(100);
108
- // Get second page
109
- mockQuery.mockResolvedValue({ rows, fields: [{ name: 'id' }] });
110
- const result2 = await executeSql({ sql: 'SELECT * FROM users', maxRows: 10, offset: 10 });
111
- expect(result2.rows).toHaveLength(10);
112
- expect(result2.rows[0].id).toBe(10);
113
- expect(result2.offset).toBe(10);
114
- expect(result2.hasMore).toBe(true);
115
- // Get last page
116
- mockQuery.mockResolvedValue({ rows, fields: [{ name: 'id' }] });
117
- const result3 = await executeSql({ sql: 'SELECT * FROM users', maxRows: 10, offset: 90 });
118
- expect(result3.rows).toHaveLength(10);
119
- expect(result3.rows[0].id).toBe(90);
120
- expect(result3.hasMore).toBe(false);
121
- });
122
- it('should support parameterized queries', async () => {
123
- mockQuery.mockResolvedValue({
124
- rows: [{ id: 1, name: 'Test' }],
125
- fields: [{ name: 'id' }, { name: 'name' }]
126
- });
127
- await executeSql({ sql: 'SELECT * FROM users WHERE id = $1', params: [123] });
128
- expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM users WHERE id = $1', [123]);
129
- });
130
- it('should validate params is an array', async () => {
131
- await expect(executeSql({ sql: 'SELECT 1', params: 'invalid' }))
132
- .rejects.toThrow('params must be an array');
133
- });
134
- it('should limit number of params', async () => {
135
- const manyParams = Array.from({ length: 101 }, (_, i) => i);
136
- await expect(executeSql({ sql: 'SELECT 1', params: manyParams }))
137
- .rejects.toThrow('Maximum 100 parameters allowed');
138
- });
139
- it('should write large results to file', async () => {
140
- const largeRows = Array.from({ length: 2000 }, (_, i) => ({ id: i, name: `User ${i}` }));
141
- mockQuery.mockResolvedValue({
142
- rows: largeRows,
143
- fields: [{ name: 'id' }, { name: 'name' }]
144
- });
145
- const result = await executeSql({ sql: 'SELECT * FROM users' });
146
- // With pagination, only first 1000 rows are returned, but if output is still large, writes to file
147
- expect(result.rowCount).toBe(2000);
148
- expect(result.hasMore).toBe(true);
149
- // Clean up if file was created
150
- if (result.outputFile) {
151
- fs.unlinkSync(result.outputFile);
152
- }
153
- });
154
- it('should validate maxRows parameter', async () => {
155
- await expect(executeSql({ sql: 'SELECT 1', maxRows: -1 }))
156
- .rejects.toThrow('maxRows must be an integer between');
157
- });
158
- it('should validate offset parameter', async () => {
159
- await expect(executeSql({ sql: 'SELECT 1', offset: -1 }))
160
- .rejects.toThrow('offset must be an integer between');
161
- });
162
- it('should handle empty result sets', async () => {
163
- mockQuery.mockResolvedValue({
164
- rows: [],
165
- fields: [{ name: 'id' }]
166
- });
167
- const result = await executeSql({ sql: 'SELECT * FROM users WHERE 1=0' });
168
- expect(result.rows).toEqual([]);
169
- expect(result.rowCount).toBe(0);
170
- expect(result.outputFile).toBeUndefined();
171
- expect(result.hasMore).toBe(false);
172
- });
173
- it('should return execution time', async () => {
174
- mockQuery.mockResolvedValue({
175
- rows: [{ id: 1 }],
176
- fields: [{ name: 'id' }]
177
- });
178
- const result = await executeSql({ sql: 'SELECT 1' });
179
- expect(result.executionTimeMs).toBeDefined();
180
- expect(typeof result.executionTimeMs).toBe('number');
181
- expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
182
- });
183
- });
184
- describe('explainQuery', () => {
185
- let mockClient;
186
- beforeEach(() => {
187
- mockClient = {
188
- query: jest.fn(),
189
- release: jest.fn()
190
- };
191
- mockGetClient.mockResolvedValue(mockClient);
192
- });
193
- it('should require sql parameter', async () => {
194
- await expect(explainQuery({ sql: '' }))
195
- .rejects.toThrow('sql parameter is required');
196
- });
197
- it('should reject SQL that is too long', async () => {
198
- const longSql = 'SELECT ' + 'a'.repeat(100001);
199
- await expect(explainQuery({ sql: longSql }))
200
- .rejects.toThrow('exceeds maximum length');
201
- });
202
- it('should return execution plan in JSON format', async () => {
203
- mockClient.query.mockResolvedValue({
204
- rows: [{ 'QUERY PLAN': [{ Plan: { 'Node Type': 'Seq Scan' } }] }]
205
- });
206
- const result = await explainQuery({ sql: 'SELECT * FROM users' });
207
- expect(result.plan).toEqual({ Plan: { 'Node Type': 'Seq Scan' } });
208
- expect(mockClient.release).toHaveBeenCalled();
209
- });
210
- it('should return execution plan in text format', async () => {
211
- mockClient.query.mockResolvedValue({
212
- rows: [
213
- { 'QUERY PLAN': 'Seq Scan on users' },
214
- { 'QUERY PLAN': ' Filter: (id = 1)' }
215
- ]
216
- });
217
- const result = await explainQuery({ sql: 'SELECT * FROM users', format: 'text' });
218
- expect(result.plan).toContain('Seq Scan');
219
- expect(mockClient.release).toHaveBeenCalled();
220
- });
221
- it('should block EXPLAIN ANALYZE on write queries', async () => {
222
- await expect(explainQuery({ sql: 'DELETE FROM users', analyze: true }))
223
- .rejects.toThrow('EXPLAIN ANALYZE is not allowed for write queries');
224
- await expect(explainQuery({ sql: 'INSERT INTO users VALUES (1)', analyze: true }))
225
- .rejects.toThrow('EXPLAIN ANALYZE is not allowed for write queries');
226
- await expect(explainQuery({ sql: 'UPDATE users SET name = \'test\'', analyze: true }))
227
- .rejects.toThrow('EXPLAIN ANALYZE is not allowed for write queries');
228
- });
229
- it('should allow EXPLAIN ANALYZE on SELECT queries', async () => {
230
- mockClient.query.mockResolvedValue({
231
- rows: [{ 'QUERY PLAN': [{ Plan: { 'Node Type': 'Seq Scan' } }] }]
232
- });
233
- const result = await explainQuery({ sql: 'SELECT * FROM users', analyze: true });
234
- expect(result).toBeDefined();
235
- const queryCall = mockClient.query.mock.calls.find((call) => typeof call[0] === 'string' && call[0].includes('EXPLAIN') && call[0].includes('ANALYZE'));
236
- expect(queryCall).toBeDefined();
237
- });
238
- it('should release client even on error', async () => {
239
- mockClient.query.mockRejectedValue(new Error('Query failed'));
240
- await expect(explainQuery({ sql: 'SELECT * FROM users' }))
241
- .rejects.toThrow('Query failed');
242
- expect(mockClient.release).toHaveBeenCalled();
243
- });
244
- describe('hypothetical indexes', () => {
245
- it('should validate table name in hypothetical indexes', async () => {
246
- // Mock hypopg check to return true
247
- mockClient.query.mockResolvedValueOnce({ rows: [{ has_hypopg: true }] });
248
- await expect(explainQuery({
249
- sql: 'SELECT * FROM users',
250
- hypotheticalIndexes: [
251
- { table: 'users; DROP TABLE', columns: ['id'] }
252
- ]
253
- })).rejects.toThrow('invalid characters');
254
- });
255
- it('should validate column names in hypothetical indexes', async () => {
256
- // Mock hypopg check to return true
257
- mockClient.query.mockResolvedValueOnce({ rows: [{ has_hypopg: true }] });
258
- await expect(explainQuery({
259
- sql: 'SELECT * FROM users',
260
- hypotheticalIndexes: [
261
- { table: 'users', columns: ['id; DROP'] }
262
- ]
263
- })).rejects.toThrow('invalid characters');
264
- });
265
- it('should validate index type', async () => {
266
- // Mock hypopg check to return true
267
- mockClient.query.mockResolvedValueOnce({ rows: [{ has_hypopg: true }] });
268
- await expect(explainQuery({
269
- sql: 'SELECT * FROM users',
270
- hypotheticalIndexes: [
271
- { table: 'users', columns: ['id'], indexType: 'invalid' }
272
- ]
273
- })).rejects.toThrow('Invalid index type');
274
- });
275
- it('should limit number of hypothetical indexes', async () => {
276
- const manyIndexes = Array.from({ length: 11 }, (_, i) => ({
277
- table: 'users',
278
- columns: [`col${i}`]
279
- }));
280
- await expect(explainQuery({
281
- sql: 'SELECT * FROM users',
282
- hypotheticalIndexes: manyIndexes
283
- })).rejects.toThrow('Maximum 10 hypothetical indexes');
284
- });
285
- it('should require columns in hypothetical indexes', async () => {
286
- // Mock hypopg check to return true
287
- mockClient.query.mockResolvedValueOnce({ rows: [{ has_hypopg: true }] });
288
- await expect(explainQuery({
289
- sql: 'SELECT * FROM users',
290
- hypotheticalIndexes: [
291
- { table: 'users', columns: [] }
292
- ]
293
- })).rejects.toThrow('columns array is required and must not be empty');
294
- });
295
- it('should handle hypothetical indexes with hypopg', async () => {
296
- mockClient.query
297
- .mockResolvedValueOnce({ rows: [{ has_hypopg: true }] })
298
- .mockResolvedValueOnce({}) // hypopg_create_index
299
- .mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: {} }] }] })
300
- .mockResolvedValueOnce({}); // hypopg_reset
301
- await explainQuery({
302
- sql: 'SELECT * FROM users',
303
- hypotheticalIndexes: [
304
- { table: 'users', columns: ['id'] }
305
- ]
306
- });
307
- // Verify parameterized call to hypopg
308
- const hypopgCall = mockClient.query.mock.calls.find((call) => typeof call[0] === 'string' && call[0].includes('hypopg_create_index'));
309
- expect(hypopgCall).toBeDefined();
310
- });
311
- });
312
- });
313
- describe('executeSqlFile', () => {
314
- let mockClient;
315
- let testDir;
316
- let testFile;
317
- beforeEach(() => {
318
- mockClient = {
319
- query: jest.fn(),
320
- release: jest.fn()
321
- };
322
- mockGetClient.mockResolvedValue(mockClient);
323
- // Create unique test directory for each test run
324
- testDir = fs.mkdtempSync('/tmp/postgres-mcp-test-');
325
- testFile = `${testDir}/test.sql`;
326
- });
327
- afterEach(() => {
328
- // Clean up test files and directory
329
- try {
330
- if (fs.existsSync(testFile)) {
331
- fs.unlinkSync(testFile);
332
- }
333
- if (fs.existsSync(testDir)) {
334
- fs.rmdirSync(testDir);
335
- }
336
- }
337
- catch (e) {
338
- // Ignore cleanup errors
339
- }
340
- });
341
- it('should require filePath parameter', async () => {
342
- await expect(executeSqlFile({ filePath: '' }))
343
- .rejects.toThrow('filePath parameter is required');
344
- });
345
- it('should only allow .sql files', async () => {
346
- await expect(executeSqlFile({ filePath: '/path/to/file.txt' }))
347
- .rejects.toThrow('Only .sql files are allowed');
348
- await expect(executeSqlFile({ filePath: '/path/to/file.js' }))
349
- .rejects.toThrow('Only .sql files are allowed');
350
- });
351
- it('should throw if file does not exist', async () => {
352
- await expect(executeSqlFile({ filePath: '/nonexistent/path/file.sql' }))
353
- .rejects.toThrow('File not found');
354
- });
355
- it('should throw if file is empty', async () => {
356
- fs.writeFileSync(testFile, '');
357
- await expect(executeSqlFile({ filePath: testFile }))
358
- .rejects.toThrow('File is empty');
359
- });
360
- it('should execute single statement successfully', async () => {
361
- fs.writeFileSync(testFile, 'SELECT 1;');
362
- mockClient.query
363
- .mockResolvedValueOnce({}) // BEGIN
364
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 1
365
- .mockResolvedValueOnce({}); // COMMIT
366
- const result = await executeSqlFile({ filePath: testFile });
367
- expect(result.success).toBe(true);
368
- expect(result.statementsExecuted).toBe(1);
369
- expect(result.statementsFailed).toBe(0);
370
- expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
371
- expect(mockClient.release).toHaveBeenCalled();
372
- });
373
- it('should execute multiple statements', async () => {
374
- fs.writeFileSync(testFile, 'SELECT 1; SELECT 2; SELECT 3;');
375
- mockClient.query
376
- .mockResolvedValueOnce({}) // BEGIN
377
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 1
378
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 2
379
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 3
380
- .mockResolvedValueOnce({}); // COMMIT
381
- const result = await executeSqlFile({ filePath: testFile });
382
- expect(result.success).toBe(true);
383
- expect(result.statementsExecuted).toBe(3);
384
- expect(result.totalStatements).toBe(3);
385
- });
386
- it('should rollback on error with useTransaction=true', async () => {
387
- fs.writeFileSync(testFile, 'SELECT 1; INVALID SQL; SELECT 3;');
388
- mockClient.query
389
- .mockResolvedValueOnce({}) // BEGIN
390
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 1
391
- .mockRejectedValueOnce(new Error('syntax error')) // INVALID SQL
392
- .mockResolvedValueOnce({}); // ROLLBACK
393
- const result = await executeSqlFile({ filePath: testFile });
394
- expect(result.success).toBe(false);
395
- expect(result.statementsExecuted).toBe(1);
396
- expect(result.statementsFailed).toBe(1);
397
- expect(result.error).toContain('syntax error');
398
- expect(result.rollback).toBe(true);
399
- expect(result.errors).toHaveLength(1);
400
- expect(result.errors[0].statementIndex).toBe(2);
401
- });
402
- it('should continue on error with stopOnError=false', async () => {
403
- fs.writeFileSync(testFile, 'SELECT 1; INVALID SQL; SELECT 3;');
404
- mockClient.query
405
- .mockResolvedValueOnce({}) // BEGIN
406
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 1
407
- .mockRejectedValueOnce(new Error('syntax error')) // INVALID SQL
408
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 3
409
- .mockResolvedValueOnce({}); // COMMIT
410
- const result = await executeSqlFile({ filePath: testFile, stopOnError: false });
411
- expect(result.success).toBe(false); // Not success because there was a failure
412
- expect(result.statementsExecuted).toBe(2);
413
- expect(result.statementsFailed).toBe(1);
414
- expect(result.errors).toHaveLength(1);
415
- expect(result.errors[0].statementIndex).toBe(2);
416
- expect(result.errors[0].error).toContain('syntax error');
417
- });
418
- it('should skip transaction with useTransaction=false', async () => {
419
- fs.writeFileSync(testFile, 'SELECT 1;');
420
- mockClient.query.mockResolvedValue({ rowCount: 1 });
421
- await executeSqlFile({ filePath: testFile, useTransaction: false });
422
- // Verify no BEGIN/COMMIT calls
423
- const calls = mockClient.query.mock.calls.map((c) => c[0]);
424
- expect(calls).not.toContain('BEGIN');
425
- expect(calls).not.toContain('COMMIT');
426
- });
427
- it('should handle comments correctly', async () => {
428
- fs.writeFileSync(testFile, `
429
- -- This is a comment
430
- SELECT 1;
431
- /* Block comment */
432
- SELECT 2;
433
- `);
434
- mockClient.query
435
- .mockResolvedValueOnce({}) // BEGIN
436
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 1
437
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 2
438
- .mockResolvedValueOnce({}); // COMMIT
439
- const result = await executeSqlFile({ filePath: testFile });
440
- expect(result.success).toBe(true);
441
- expect(result.statementsExecuted).toBe(2);
442
- });
443
- it('should handle dollar-quoted strings', async () => {
444
- fs.writeFileSync(testFile, `
445
- SELECT $tag$This has a ; semicolon$tag$;
446
- SELECT 2;
447
- `);
448
- mockClient.query
449
- .mockResolvedValueOnce({}) // BEGIN
450
- .mockResolvedValueOnce({ rowCount: 1 }) // First SELECT
451
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 2
452
- .mockResolvedValueOnce({}); // COMMIT
453
- const result = await executeSqlFile({ filePath: testFile });
454
- expect(result.success).toBe(true);
455
- expect(result.statementsExecuted).toBe(2);
456
- });
457
- it('should return file info in result', async () => {
458
- const content = 'SELECT 1;';
459
- fs.writeFileSync(testFile, content);
460
- mockClient.query
461
- .mockResolvedValueOnce({}) // BEGIN
462
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 1
463
- .mockResolvedValueOnce({}); // COMMIT
464
- const result = await executeSqlFile({ filePath: testFile });
465
- expect(result.filePath).toContain('test.sql');
466
- expect(result.fileSize).toBe(content.length);
467
- });
468
- it('should release client even on error', async () => {
469
- fs.writeFileSync(testFile, 'SELECT 1;');
470
- mockClient.query.mockRejectedValue(new Error('Connection error'));
471
- try {
472
- await executeSqlFile({ filePath: testFile });
473
- }
474
- catch (e) {
475
- // Expected
476
- }
477
- expect(mockClient.release).toHaveBeenCalled();
478
- });
479
- it('should include line numbers in errors', async () => {
480
- fs.writeFileSync(testFile, `
481
- SELECT 1;
482
- SELECT 2;
483
- INVALID SYNTAX HERE;
484
- SELECT 4;
485
- `);
486
- mockClient.query
487
- .mockResolvedValueOnce({}) // BEGIN
488
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 1
489
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 2
490
- .mockRejectedValueOnce(new Error('syntax error')) // INVALID
491
- .mockResolvedValueOnce({}); // ROLLBACK
492
- const result = await executeSqlFile({ filePath: testFile, stopOnError: true });
493
- expect(result.success).toBe(false);
494
- expect(result.errors).toBeDefined();
495
- expect(result.errors[0].lineNumber).toBeGreaterThan(0);
496
- expect(result.errors[0].statementIndex).toBe(3);
497
- });
498
- it('should track line numbers correctly with multi-line statements', async () => {
499
- fs.writeFileSync(testFile, `-- Comment on line 1
500
- SELECT
501
- column1,
502
- column2
503
- FROM table1;
504
- -- Line 6
505
- SELECT 1;
506
- INVALID;`);
507
- mockClient.query
508
- .mockResolvedValueOnce({}) // BEGIN
509
- .mockResolvedValueOnce({ rowCount: 1 }) // First SELECT
510
- .mockResolvedValueOnce({ rowCount: 1 }) // SELECT 1
511
- .mockRejectedValueOnce(new Error('syntax error')); // INVALID
512
- const result = await executeSqlFile({ filePath: testFile, stopOnError: false });
513
- expect(result.errors).toBeDefined();
514
- expect(result.errors.length).toBe(1);
515
- // The INVALID statement starts on line 8
516
- expect(result.errors[0].lineNumber).toBe(8);
517
- });
518
- });
519
- describe('previewSqlFile', () => {
520
- let testDir;
521
- let testFile;
522
- beforeEach(() => {
523
- // Create unique test directory for each test run
524
- testDir = fs.mkdtempSync('/tmp/postgres-mcp-preview-test-');
525
- testFile = `${testDir}/test.sql`;
526
- });
527
- afterEach(() => {
528
- // Clean up test files and directory
529
- try {
530
- if (fs.existsSync(testFile)) {
531
- fs.unlinkSync(testFile);
532
- }
533
- if (fs.existsSync(testDir)) {
534
- fs.rmdirSync(testDir);
535
- }
536
- }
537
- catch (e) {
538
- // Ignore cleanup errors
539
- }
540
- });
541
- it('should require filePath parameter', async () => {
542
- const { previewSqlFile } = await import('../tools/sql-tools.js');
543
- await expect(previewSqlFile({ filePath: '' }))
544
- .rejects.toThrow('filePath parameter is required');
545
- });
546
- it('should only allow .sql files', async () => {
547
- const { previewSqlFile } = await import('../tools/sql-tools.js');
548
- await expect(previewSqlFile({ filePath: '/path/to/file.txt' }))
549
- .rejects.toThrow('Only .sql files are allowed');
550
- });
551
- it('should throw if file does not exist', async () => {
552
- const { previewSqlFile } = await import('../tools/sql-tools.js');
553
- await expect(previewSqlFile({ filePath: '/nonexistent/path/file.sql' }))
554
- .rejects.toThrow('File not found');
555
- });
556
- it('should throw if file is empty', async () => {
557
- const { previewSqlFile } = await import('../tools/sql-tools.js');
558
- fs.writeFileSync(testFile, '');
559
- await expect(previewSqlFile({ filePath: testFile }))
560
- .rejects.toThrow('File is empty');
561
- });
562
- it('should preview a simple SQL file', async () => {
563
- const { previewSqlFile } = await import('../tools/sql-tools.js');
564
- fs.writeFileSync(testFile, 'SELECT 1; SELECT 2; SELECT 3;');
565
- const result = await previewSqlFile({ filePath: testFile });
566
- expect(result.totalStatements).toBe(3);
567
- expect(result.statementsByType['SELECT']).toBe(3);
568
- expect(result.statements).toHaveLength(3);
569
- expect(result.warnings).toHaveLength(0);
570
- expect(result.summary).toContain('3 statements');
571
- });
572
- it('should count statement types correctly', async () => {
573
- const { previewSqlFile } = await import('../tools/sql-tools.js');
574
- fs.writeFileSync(testFile, `
575
- CREATE TABLE test (id INT);
576
- INSERT INTO test VALUES (1);
577
- INSERT INTO test VALUES (2);
578
- UPDATE test SET id = 3 WHERE id = 1;
579
- SELECT * FROM test;
580
- DELETE FROM test WHERE id = 2;
581
- DROP TABLE test;
582
- `);
583
- const result = await previewSqlFile({ filePath: testFile });
584
- expect(result.totalStatements).toBe(7);
585
- expect(result.statementsByType['CREATE']).toBe(1);
586
- expect(result.statementsByType['INSERT']).toBe(2);
587
- expect(result.statementsByType['UPDATE']).toBe(1);
588
- expect(result.statementsByType['SELECT']).toBe(1);
589
- expect(result.statementsByType['DELETE']).toBe(1);
590
- expect(result.statementsByType['DROP']).toBe(1);
591
- });
592
- it('should warn about DROP statements', async () => {
593
- const { previewSqlFile } = await import('../tools/sql-tools.js');
594
- fs.writeFileSync(testFile, `
595
- CREATE TABLE test (id INT);
596
- DROP TABLE test;
597
- DROP INDEX idx_test;
598
- `);
599
- const result = await previewSqlFile({ filePath: testFile });
600
- expect(result.warnings.length).toBeGreaterThan(0);
601
- expect(result.warnings.some(w => w.includes('DROP'))).toBe(true);
602
- });
603
- it('should warn about TRUNCATE statements', async () => {
604
- const { previewSqlFile } = await import('../tools/sql-tools.js');
605
- fs.writeFileSync(testFile, `TRUNCATE TABLE users;`);
606
- const result = await previewSqlFile({ filePath: testFile });
607
- expect(result.warnings.length).toBe(1);
608
- expect(result.warnings[0]).toContain('TRUNCATE');
609
- });
610
- it('should warn about DELETE without WHERE', async () => {
611
- const { previewSqlFile } = await import('../tools/sql-tools.js');
612
- fs.writeFileSync(testFile, `DELETE FROM users;`);
613
- const result = await previewSqlFile({ filePath: testFile });
614
- expect(result.warnings.length).toBe(1);
615
- expect(result.warnings[0]).toContain('DELETE without WHERE');
616
- });
617
- it('should warn about UPDATE without WHERE', async () => {
618
- const { previewSqlFile } = await import('../tools/sql-tools.js');
619
- fs.writeFileSync(testFile, `UPDATE users SET status = 'inactive';`);
620
- const result = await previewSqlFile({ filePath: testFile });
621
- expect(result.warnings.length).toBe(1);
622
- expect(result.warnings[0]).toContain('UPDATE without WHERE');
623
- });
624
- it('should NOT warn about DELETE with WHERE', async () => {
625
- const { previewSqlFile } = await import('../tools/sql-tools.js');
626
- fs.writeFileSync(testFile, `DELETE FROM users WHERE id = 1;`);
627
- const result = await previewSqlFile({ filePath: testFile });
628
- expect(result.warnings).toHaveLength(0);
629
- });
630
- it('should NOT warn about UPDATE with WHERE', async () => {
631
- const { previewSqlFile } = await import('../tools/sql-tools.js');
632
- fs.writeFileSync(testFile, `UPDATE users SET status = 'inactive' WHERE id = 1;`);
633
- const result = await previewSqlFile({ filePath: testFile });
634
- expect(result.warnings).toHaveLength(0);
635
- });
636
- it('should strip patterns before parsing', async () => {
637
- const { previewSqlFile } = await import('../tools/sql-tools.js');
638
- fs.writeFileSync(testFile, `
639
- SELECT 1;
640
- /
641
- SELECT 2;
642
- /
643
- `);
644
- const result = await previewSqlFile({
645
- filePath: testFile,
646
- stripPatterns: ['/']
647
- });
648
- expect(result.totalStatements).toBe(2);
649
- expect(result.statementsByType['SELECT']).toBe(2);
650
- });
651
- it('should strip regex patterns before parsing', async () => {
652
- const { previewSqlFile } = await import('../tools/sql-tools.js');
653
- fs.writeFileSync(testFile, `
654
- SELECT 1;
655
- GO
656
- SELECT 2;
657
- GO
658
- SELECT 3;
659
- `);
660
- const result = await previewSqlFile({
661
- filePath: testFile,
662
- stripPatterns: ['^\\s*GO\\s*$'],
663
- stripAsRegex: true
664
- });
665
- expect(result.totalStatements).toBe(3);
666
- });
667
- it('should limit statements returned based on maxStatements', async () => {
668
- const { previewSqlFile } = await import('../tools/sql-tools.js');
669
- // Create file with 30 statements
670
- const statements = Array(30).fill('SELECT 1;').join('\n');
671
- fs.writeFileSync(testFile, statements);
672
- const result = await previewSqlFile({
673
- filePath: testFile,
674
- maxStatements: 10
675
- });
676
- expect(result.totalStatements).toBe(30);
677
- expect(result.statements).toHaveLength(10);
678
- });
679
- it('should format file size correctly', async () => {
680
- const { previewSqlFile } = await import('../tools/sql-tools.js');
681
- // Create a larger file
682
- const content = 'SELECT * FROM large_table;\n'.repeat(100);
683
- fs.writeFileSync(testFile, content);
684
- const result = await previewSqlFile({ filePath: testFile });
685
- expect(result.fileSize).toBeGreaterThan(0);
686
- expect(result.fileSizeFormatted).toBeDefined();
687
- expect(typeof result.fileSizeFormatted).toBe('string');
688
- });
689
- it('should include line numbers in statement preview', async () => {
690
- const { previewSqlFile } = await import('../tools/sql-tools.js');
691
- // Write file with explicit line structure
692
- fs.writeFileSync(testFile, '-- Comment line 1\nSELECT 1;\n-- Comment line 3\nSELECT 2;\nSELECT 3;');
693
- const result = await previewSqlFile({ filePath: testFile });
694
- // Verify statements have line numbers assigned
695
- expect(result.statements.length).toBe(3);
696
- expect(result.statements[0].lineNumber).toBeGreaterThan(0);
697
- expect(result.statements[1].lineNumber).toBeGreaterThan(result.statements[0].lineNumber);
698
- expect(result.statements[2].lineNumber).toBeGreaterThan(result.statements[1].lineNumber);
699
- });
700
- it('should truncate long SQL statements', async () => {
701
- const { previewSqlFile } = await import('../tools/sql-tools.js');
702
- const longSelect = 'SELECT ' + 'a, '.repeat(200) + 'b FROM table1;';
703
- fs.writeFileSync(testFile, longSelect);
704
- const result = await previewSqlFile({ filePath: testFile });
705
- expect(result.statements[0].sql.length).toBeLessThanOrEqual(303); // 300 + '...'
706
- expect(result.statements[0].sql).toContain('...');
707
- });
708
- it('should handle multi-line statements correctly', async () => {
709
- const { previewSqlFile } = await import('../tools/sql-tools.js');
710
- fs.writeFileSync(testFile, `
711
- CREATE TABLE users (
712
- id SERIAL PRIMARY KEY,
713
- name VARCHAR(100),
714
- email VARCHAR(255)
715
- );
716
-
717
- INSERT INTO users (name, email)
718
- VALUES ('John', 'john@example.com');
719
- `);
720
- const result = await previewSqlFile({ filePath: testFile });
721
- expect(result.totalStatements).toBe(2);
722
- expect(result.statementsByType['CREATE']).toBe(1);
723
- expect(result.statementsByType['INSERT']).toBe(1);
724
- });
725
- it('should handle dollar-quoted strings', async () => {
726
- const { previewSqlFile } = await import('../tools/sql-tools.js');
727
- fs.writeFileSync(testFile, `
728
- CREATE FUNCTION test() RETURNS void AS $$
729
- BEGIN
730
- -- This semicolon should not split the statement;
731
- RAISE NOTICE 'Hello';
732
- END;
733
- $$ LANGUAGE plpgsql;
734
-
735
- SELECT 1;
736
- `);
737
- const result = await previewSqlFile({ filePath: testFile });
738
- expect(result.totalStatements).toBe(2);
739
- expect(result.statementsByType['CREATE']).toBe(1);
740
- expect(result.statementsByType['SELECT']).toBe(1);
741
- });
742
- it('should generate meaningful summary', async () => {
743
- const { previewSqlFile } = await import('../tools/sql-tools.js');
744
- fs.writeFileSync(testFile, `
745
- CREATE TABLE t1 (id INT);
746
- CREATE TABLE t2 (id INT);
747
- INSERT INTO t1 VALUES (1);
748
- SELECT * FROM t1;
749
- `);
750
- const result = await previewSqlFile({ filePath: testFile });
751
- expect(result.summary).toContain('4 statements');
752
- expect(result.summary).toContain('CREATE');
753
- expect(result.summary).toContain('INSERT');
754
- expect(result.summary).toContain('SELECT');
755
- });
756
- it('should handle file with only comments', async () => {
757
- const { previewSqlFile } = await import('../tools/sql-tools.js');
758
- fs.writeFileSync(testFile, `
759
- -- This is a comment
760
- /* This is a block comment */
761
- -- Another comment
762
- `);
763
- const result = await previewSqlFile({ filePath: testFile });
764
- expect(result.totalStatements).toBe(0);
765
- expect(result.statements).toHaveLength(0);
766
- expect(result.summary).toContain('0 statement');
767
- });
768
- it('should handle CTE queries (WITH statements)', async () => {
769
- const { previewSqlFile } = await import('../tools/sql-tools.js');
770
- fs.writeFileSync(testFile, `
771
- WITH cte AS (SELECT 1 AS n)
772
- SELECT * FROM cte;
773
-
774
- WITH ins AS (
775
- INSERT INTO test (name) VALUES ('test')
776
- RETURNING *
777
- )
778
- SELECT * FROM ins;
779
- `);
780
- const result = await previewSqlFile({ filePath: testFile });
781
- expect(result.totalStatements).toBe(2);
782
- // Both WITH statements end with SELECT (to get the results)
783
- // so they should be detected as WITH SELECT
784
- expect(result.statementsByType['WITH SELECT']).toBe(2);
785
- });
786
- it('should handle multiple dangerous operations and generate multiple warnings', async () => {
787
- const { previewSqlFile } = await import('../tools/sql-tools.js');
788
- fs.writeFileSync(testFile, `
789
- DROP TABLE users;
790
- DROP TABLE orders;
791
- TRUNCATE TABLE logs;
792
- DELETE FROM sessions;
793
- UPDATE config SET value = 'new';
794
- `);
795
- const result = await previewSqlFile({ filePath: testFile });
796
- expect(result.warnings.length).toBe(5);
797
- expect(result.warnings.filter(w => w.includes('DROP')).length).toBe(2);
798
- expect(result.warnings.filter(w => w.includes('TRUNCATE')).length).toBe(1);
799
- expect(result.warnings.filter(w => w.includes('DELETE without WHERE')).length).toBe(1);
800
- expect(result.warnings.filter(w => w.includes('UPDATE without WHERE')).length).toBe(1);
801
- });
802
- });
803
- describe('mutationDryRun', () => {
804
- let mockClient;
805
- beforeEach(() => {
806
- mockClient = {
807
- query: jest.fn(),
808
- release: jest.fn()
809
- };
810
- mockGetClient.mockResolvedValue(mockClient);
811
- });
812
- it('should require sql parameter', async () => {
813
- const { mutationDryRun } = await import('../tools/sql-tools.js');
814
- await expect(mutationDryRun({ sql: '' }))
815
- .rejects.toThrow('sql parameter is required');
816
- });
817
- it('should only accept INSERT/UPDATE/DELETE statements', async () => {
818
- const { mutationDryRun } = await import('../tools/sql-tools.js');
819
- await expect(mutationDryRun({ sql: 'SELECT * FROM users' }))
820
- .rejects.toThrow('SQL must be an INSERT, UPDATE, or DELETE statement');
821
- });
822
- it('should execute UPDATE in transaction and rollback', async () => {
823
- const { mutationDryRun } = await import('../tools/sql-tools.js');
824
- mockClient.query
825
- .mockResolvedValueOnce({}) // BEGIN
826
- .mockResolvedValueOnce({ rows: [{ id: 1, name: 'old' }] }) // SELECT before
827
- .mockResolvedValueOnce({ rows: [{ id: 1, name: 'new' }], rowCount: 1 }) // UPDATE RETURNING
828
- .mockResolvedValueOnce({}); // ROLLBACK
829
- const result = await mutationDryRun({
830
- sql: "UPDATE users SET name = 'new' WHERE id = 1"
831
- });
832
- expect(result.success).toBe(true);
833
- expect(result.mutationType).toBe('UPDATE');
834
- expect(result.rowsAffected).toBe(1);
835
- expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
836
- expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
837
- });
838
- it('should execute DELETE in transaction and rollback', async () => {
839
- const { mutationDryRun } = await import('../tools/sql-tools.js');
840
- mockClient.query
841
- .mockResolvedValueOnce({}) // BEGIN
842
- .mockResolvedValueOnce({ rows: [{ id: 1 }] }) // SELECT before
843
- .mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // DELETE RETURNING
844
- .mockResolvedValueOnce({}); // ROLLBACK
845
- const result = await mutationDryRun({
846
- sql: 'DELETE FROM users WHERE id = 1'
847
- });
848
- expect(result.success).toBe(true);
849
- expect(result.mutationType).toBe('DELETE');
850
- expect(result.rowsAffected).toBe(1);
851
- });
852
- it('should execute INSERT in transaction and rollback', async () => {
853
- const { mutationDryRun } = await import('../tools/sql-tools.js');
854
- mockClient.query
855
- .mockResolvedValueOnce({}) // BEGIN
856
- .mockResolvedValueOnce({ rows: [{ id: 1, name: 'test' }], rowCount: 1 }) // INSERT RETURNING
857
- .mockResolvedValueOnce({}); // ROLLBACK
858
- const result = await mutationDryRun({
859
- sql: "INSERT INTO users (name) VALUES ('test')"
860
- });
861
- expect(result.success).toBe(true);
862
- expect(result.mutationType).toBe('INSERT');
863
- expect(result.rowsAffected).toBe(1);
864
- });
865
- it('should capture PostgreSQL error details on failure', async () => {
866
- const { mutationDryRun } = await import('../tools/sql-tools.js');
867
- const pgError = new Error('duplicate key value violates unique constraint');
868
- pgError.code = '23505';
869
- pgError.constraint = 'users_email_key';
870
- pgError.detail = 'Key (email)=(test@test.com) already exists.';
871
- pgError.table = 'users';
872
- mockClient.query
873
- .mockResolvedValueOnce({}) // BEGIN
874
- .mockRejectedValueOnce(pgError) // INSERT RETURNING fails
875
- .mockRejectedValueOnce(pgError) // INSERT without RETURNING also fails
876
- .mockResolvedValueOnce({}); // ROLLBACK
877
- const result = await mutationDryRun({
878
- sql: "INSERT INTO users (email) VALUES ('test@test.com')"
879
- });
880
- expect(result.success).toBe(false);
881
- expect(result.error).toBeDefined();
882
- expect(result.error.code).toBe('23505');
883
- expect(result.error.constraint).toBe('users_email_key');
884
- expect(result.error.detail).toContain('already exists');
885
- expect(result.error.table).toBe('users');
886
- });
887
- it('should warn about UPDATE without WHERE', async () => {
888
- const { mutationDryRun } = await import('../tools/sql-tools.js');
889
- mockClient.query
890
- .mockResolvedValueOnce({}) // BEGIN
891
- .mockResolvedValueOnce({ rows: [{ id: 1 }] }) // SELECT before
892
- .mockResolvedValueOnce({ rows: [], rowCount: 100 }) // UPDATE
893
- .mockResolvedValueOnce({}); // ROLLBACK
894
- const result = await mutationDryRun({
895
- sql: "UPDATE users SET status = 'inactive'"
896
- });
897
- expect(result.warnings).toBeDefined();
898
- expect(result.warnings.some(w => w.includes('No WHERE clause'))).toBe(true);
899
- });
900
- it('should detect INSERT sequence warnings (but still execute)', async () => {
901
- const { mutationDryRun } = await import('../tools/sql-tools.js');
902
- mockClient.query
903
- .mockResolvedValueOnce({}) // BEGIN
904
- .mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // INSERT
905
- .mockResolvedValueOnce({}); // ROLLBACK
906
- const result = await mutationDryRun({
907
- sql: "INSERT INTO users (name) VALUES ('test')"
908
- });
909
- // INSERT should execute (not skipped) but have a warning
910
- expect(result.skipped).toBeUndefined();
911
- expect(result.success).toBe(true);
912
- expect(result.nonRollbackableWarnings).toBeDefined();
913
- expect(result.nonRollbackableWarnings.some(w => w.operation === 'SEQUENCE')).toBe(true);
914
- expect(result.nonRollbackableWarnings.some(w => w.mustSkip === false)).toBe(true);
915
- });
916
- it('should skip INSERT with explicit NEXTVAL but run EXPLAIN', async () => {
917
- const { mutationDryRun } = await import('../tools/sql-tools.js');
918
- // Mock EXPLAIN query
919
- mockQuery.mockResolvedValueOnce({
920
- rows: [{ 'QUERY PLAN': [{ Plan: { 'Node Type': 'ModifyTable' } }] }],
921
- rowCount: 1
922
- });
923
- const result = await mutationDryRun({
924
- sql: "INSERT INTO users (id, name) VALUES (nextval('users_id_seq'), 'test')"
925
- });
926
- expect(result.skipped).toBe(true);
927
- expect(result.skipReason).toContain('NEXTVAL');
928
- expect(result.rowsAffected).toBe(0);
929
- expect(result.nonRollbackableWarnings).toBeDefined();
930
- expect(result.nonRollbackableWarnings.some(w => w.operation === 'SEQUENCE' && w.mustSkip === true)).toBe(true);
931
- // Should have EXPLAIN plan
932
- expect(result.explainPlan).toBeDefined();
933
- expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('EXPLAIN'));
934
- });
935
- it('should capture before and after rows for UPDATE', async () => {
936
- const { mutationDryRun } = await import('../tools/sql-tools.js');
937
- mockClient.query
938
- .mockResolvedValueOnce({}) // BEGIN
939
- .mockResolvedValueOnce({ rows: [{ id: 1, status: 'active' }] }) // SELECT before
940
- .mockResolvedValueOnce({ rows: [{ id: 1, status: 'inactive' }], rowCount: 1 }) // UPDATE RETURNING
941
- .mockResolvedValueOnce({}); // ROLLBACK
942
- const result = await mutationDryRun({
943
- sql: "UPDATE users SET status = 'inactive' WHERE id = 1"
944
- });
945
- expect(result.beforeRows).toBeDefined();
946
- expect(result.beforeRows[0].status).toBe('active');
947
- expect(result.affectedRows).toBeDefined();
948
- expect(result.affectedRows[0].status).toBe('inactive');
949
- });
950
- it('should handle CTE UPDATE statements', async () => {
951
- const { mutationDryRun } = await import('../tools/sql-tools.js');
952
- mockClient.query
953
- .mockResolvedValueOnce({}) // BEGIN
954
- .mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // UPDATE
955
- .mockResolvedValueOnce({}); // ROLLBACK
956
- const result = await mutationDryRun({
957
- sql: "WITH updated AS (UPDATE users SET status = 'inactive' WHERE id = 1 RETURNING *) SELECT * FROM updated"
958
- });
959
- expect(result.mutationType).toBe('UPDATE');
960
- });
961
- it('should always rollback even on error', async () => {
962
- const { mutationDryRun } = await import('../tools/sql-tools.js');
963
- const error = new Error('Some error');
964
- mockClient.query
965
- .mockResolvedValueOnce({}) // BEGIN
966
- .mockRejectedValueOnce(error) // INSERT RETURNING fails
967
- .mockRejectedValueOnce(error) // INSERT without RETURNING also fails
968
- .mockResolvedValueOnce({}); // ROLLBACK
969
- const result = await mutationDryRun({
970
- sql: "INSERT INTO users (name) VALUES ('test')"
971
- });
972
- expect(result.success).toBe(false);
973
- expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
974
- });
975
- });
976
- describe('dryRunSqlFile', () => {
977
- let mockClient;
978
- let testDir;
979
- let testFile;
980
- beforeEach(() => {
981
- mockClient = {
982
- query: jest.fn(),
983
- release: jest.fn()
984
- };
985
- mockGetClient.mockResolvedValue(mockClient);
986
- testDir = fs.mkdtempSync('/tmp/postgres-mcp-dryrun-test-');
987
- testFile = `${testDir}/test.sql`;
988
- });
989
- afterEach(() => {
990
- try {
991
- if (fs.existsSync(testFile))
992
- fs.unlinkSync(testFile);
993
- if (fs.existsSync(testDir))
994
- fs.rmdirSync(testDir);
995
- }
996
- catch (e) { /* ignore */ }
997
- });
998
- it('should require filePath parameter', async () => {
999
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1000
- await expect(dryRunSqlFile({ filePath: '' }))
1001
- .rejects.toThrow('filePath parameter is required');
1002
- });
1003
- it('should only allow .sql files', async () => {
1004
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1005
- await expect(dryRunSqlFile({ filePath: '/path/to/file.txt' }))
1006
- .rejects.toThrow('Only .sql files are allowed');
1007
- });
1008
- it('should execute all statements and rollback', async () => {
1009
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1010
- fs.writeFileSync(testFile, 'SELECT 1; SELECT 2; SELECT 3;');
1011
- mockClient.query
1012
- .mockResolvedValueOnce({}) // BEGIN
1013
- .mockResolvedValueOnce({ rows: [{ '?column?': 1 }], rowCount: 1 }) // SELECT 1
1014
- .mockResolvedValueOnce({ rows: [{ '?column?': 2 }], rowCount: 1 }) // SELECT 2
1015
- .mockResolvedValueOnce({ rows: [{ '?column?': 3 }], rowCount: 1 }) // SELECT 3
1016
- .mockResolvedValueOnce({}); // ROLLBACK
1017
- const result = await dryRunSqlFile({ filePath: testFile });
1018
- expect(result.success).toBe(true);
1019
- expect(result.totalStatements).toBe(3);
1020
- expect(result.successCount).toBe(3);
1021
- expect(result.failureCount).toBe(0);
1022
- expect(result.rolledBack).toBe(true);
1023
- expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
1024
- expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
1025
- });
1026
- it('should capture errors with line numbers', async () => {
1027
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1028
- fs.writeFileSync(testFile, `SELECT 1;
1029
- SELECT 2;
1030
- INVALID SQL;
1031
- SELECT 4;`);
1032
- const pgError = new Error('syntax error at or near "INVALID"');
1033
- pgError.code = '42601';
1034
- pgError.position = '1';
1035
- mockClient.query
1036
- .mockResolvedValueOnce({}) // BEGIN
1037
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // SELECT 1
1038
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // SELECT 2
1039
- .mockRejectedValueOnce(pgError) // INVALID
1040
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // SELECT 4
1041
- .mockResolvedValueOnce({}); // ROLLBACK
1042
- const result = await dryRunSqlFile({ filePath: testFile, stopOnError: false });
1043
- expect(result.success).toBe(false);
1044
- expect(result.failureCount).toBe(1);
1045
- expect(result.statementResults[2].success).toBe(false);
1046
- expect(result.statementResults[2].lineNumber).toBe(3);
1047
- expect(result.statementResults[2].error).toBeDefined();
1048
- expect(result.statementResults[2].error.code).toBe('42601');
1049
- });
1050
- it('should stop on first error when stopOnError is true', async () => {
1051
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1052
- fs.writeFileSync(testFile, `SELECT 1;
1053
- INVALID;
1054
- SELECT 3;`);
1055
- mockClient.query
1056
- .mockResolvedValueOnce({}) // BEGIN
1057
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // SELECT 1
1058
- .mockRejectedValueOnce(new Error('syntax error')) // INVALID
1059
- .mockResolvedValueOnce({}); // ROLLBACK
1060
- const result = await dryRunSqlFile({ filePath: testFile, stopOnError: true });
1061
- expect(result.success).toBe(false);
1062
- expect(result.statementResults.length).toBe(2); // Only first two, stopped at error
1063
- expect(result.successCount).toBe(1);
1064
- expect(result.failureCount).toBe(1);
1065
- });
1066
- it('should continue on error when stopOnError is false', async () => {
1067
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1068
- fs.writeFileSync(testFile, `SELECT 1;
1069
- INVALID;
1070
- SELECT 3;`);
1071
- mockClient.query
1072
- .mockResolvedValueOnce({}) // BEGIN
1073
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // SELECT 1
1074
- .mockRejectedValueOnce(new Error('syntax error')) // INVALID
1075
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // SELECT 3
1076
- .mockResolvedValueOnce({}); // ROLLBACK
1077
- const result = await dryRunSqlFile({ filePath: testFile, stopOnError: false });
1078
- expect(result.statementResults.length).toBe(3);
1079
- expect(result.successCount).toBe(2);
1080
- expect(result.failureCount).toBe(1);
1081
- });
1082
- it('should skip non-rollbackable operations (NEXTVAL) but run EXPLAIN', async () => {
1083
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1084
- fs.writeFileSync(testFile, `INSERT INTO users (name) VALUES ('test');
1085
- SELECT nextval('users_id_seq');`);
1086
- mockClient.query
1087
- .mockResolvedValueOnce({}) // BEGIN
1088
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT (with warning but executed)
1089
- .mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Node Type': 'Result' } }] }] }) // EXPLAIN for nextval
1090
- .mockResolvedValueOnce({}); // ROLLBACK
1091
- const result = await dryRunSqlFile({ filePath: testFile });
1092
- expect(result.nonRollbackableWarnings.length).toBeGreaterThan(0);
1093
- expect(result.nonRollbackableWarnings.some(w => w.operation === 'SEQUENCE')).toBe(true);
1094
- expect(result.skippedCount).toBe(1);
1095
- expect(result.statementResults[1].skipped).toBe(true);
1096
- expect(result.statementResults[1].skipReason).toContain('NEXTVAL');
1097
- // Should have EXPLAIN plan for skipped statement
1098
- expect(result.statementResults[1].explainPlan).toBeDefined();
1099
- });
1100
- it('should skip VACUUM operations', async () => {
1101
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1102
- fs.writeFileSync(testFile, `SELECT 1;
1103
- VACUUM users;`);
1104
- // VACUUM is now SKIPPED, not executed
1105
- mockClient.query
1106
- .mockResolvedValueOnce({}) // BEGIN
1107
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // SELECT 1 (VACUUM is SKIPPED)
1108
- .mockResolvedValueOnce({}); // ROLLBACK
1109
- const result = await dryRunSqlFile({ filePath: testFile });
1110
- expect(result.nonRollbackableWarnings.some(w => w.operation === 'VACUUM')).toBe(true);
1111
- expect(result.skippedCount).toBe(1);
1112
- expect(result.statementResults[1].skipped).toBe(true);
1113
- expect(result.failureCount).toBe(0); // Not a failure, just skipped
1114
- });
1115
- it('should strip patterns before execution', async () => {
1116
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1117
- fs.writeFileSync(testFile, `SELECT 1;
1118
- /
1119
- SELECT 2;
1120
- /`);
1121
- mockClient.query
1122
- .mockResolvedValueOnce({}) // BEGIN
1123
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // SELECT 1
1124
- .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // SELECT 2
1125
- .mockResolvedValueOnce({}); // ROLLBACK
1126
- const result = await dryRunSqlFile({
1127
- filePath: testFile,
1128
- stripPatterns: ['/']
1129
- });
1130
- expect(result.totalStatements).toBe(2);
1131
- expect(result.success).toBe(true);
1132
- });
1133
- it('should include execution time for each statement', async () => {
1134
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1135
- fs.writeFileSync(testFile, 'SELECT 1; SELECT 2;');
1136
- mockClient.query
1137
- .mockResolvedValueOnce({}) // BEGIN
1138
- .mockResolvedValueOnce({ rows: [], rowCount: 1 })
1139
- .mockResolvedValueOnce({ rows: [], rowCount: 1 })
1140
- .mockResolvedValueOnce({}); // ROLLBACK
1141
- const result = await dryRunSqlFile({ filePath: testFile });
1142
- result.statementResults.forEach(stmt => {
1143
- expect(stmt.executionTimeMs).toBeDefined();
1144
- expect(typeof stmt.executionTimeMs).toBe('number');
1145
- });
1146
- });
1147
- it('should generate comprehensive summary', async () => {
1148
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1149
- fs.writeFileSync(testFile, `CREATE TABLE test (id INT);
1150
- INSERT INTO test VALUES (1);
1151
- SELECT * FROM test;`);
1152
- mockClient.query
1153
- .mockResolvedValueOnce({}) // BEGIN
1154
- .mockResolvedValueOnce({ rowCount: 0 }) // CREATE
1155
- .mockResolvedValueOnce({ rowCount: 1 }) // INSERT
1156
- .mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // SELECT
1157
- .mockResolvedValueOnce({}); // ROLLBACK
1158
- const result = await dryRunSqlFile({ filePath: testFile });
1159
- expect(result.summary).toContain('Dry-run');
1160
- expect(result.summary).toContain('3 statements');
1161
- expect(result.summary).toContain('succeeded');
1162
- expect(result.summary).toContain('rolled back');
1163
- });
1164
- it('should include sample rows in results', async () => {
1165
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1166
- fs.writeFileSync(testFile, 'SELECT id, name FROM users;');
1167
- mockClient.query
1168
- .mockResolvedValueOnce({}) // BEGIN
1169
- .mockResolvedValueOnce({
1170
- rows: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
1171
- rowCount: 2
1172
- })
1173
- .mockResolvedValueOnce({}); // ROLLBACK
1174
- const result = await dryRunSqlFile({ filePath: testFile });
1175
- expect(result.statementResults[0].rows).toBeDefined();
1176
- expect(result.statementResults[0].rows.length).toBe(2);
1177
- });
1178
- it('should capture detailed constraint violation errors', async () => {
1179
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1180
- fs.writeFileSync(testFile, "INSERT INTO users (email) VALUES ('duplicate@test.com');");
1181
- const pgError = new Error('duplicate key value violates unique constraint "users_email_key"');
1182
- pgError.code = '23505';
1183
- pgError.severity = 'ERROR';
1184
- pgError.detail = 'Key (email)=(duplicate@test.com) already exists.';
1185
- pgError.schema = 'public';
1186
- pgError.table = 'users';
1187
- pgError.constraint = 'users_email_key';
1188
- mockClient.query
1189
- .mockResolvedValueOnce({}) // BEGIN
1190
- .mockRejectedValueOnce(pgError) // INSERT fails
1191
- .mockResolvedValueOnce({}); // ROLLBACK
1192
- const result = await dryRunSqlFile({ filePath: testFile });
1193
- expect(result.success).toBe(false);
1194
- expect(result.statementResults[0].error).toBeDefined();
1195
- const error = result.statementResults[0].error;
1196
- expect(error.code).toBe('23505');
1197
- expect(error.severity).toBe('ERROR');
1198
- expect(error.detail).toContain('already exists');
1199
- expect(error.schema).toBe('public');
1200
- expect(error.table).toBe('users');
1201
- expect(error.constraint).toBe('users_email_key');
1202
- });
1203
- it('should capture foreign key violation errors', async () => {
1204
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1205
- fs.writeFileSync(testFile, 'INSERT INTO orders (user_id) VALUES (999);');
1206
- const pgError = new Error('insert or update on table "orders" violates foreign key constraint');
1207
- pgError.code = '23503';
1208
- pgError.detail = 'Key (user_id)=(999) is not present in table "users".';
1209
- pgError.constraint = 'orders_user_id_fkey';
1210
- mockClient.query
1211
- .mockResolvedValueOnce({}) // BEGIN
1212
- .mockRejectedValueOnce(pgError)
1213
- .mockResolvedValueOnce({}); // ROLLBACK
1214
- const result = await dryRunSqlFile({ filePath: testFile });
1215
- expect(result.statementResults[0].error.code).toBe('23503');
1216
- expect(result.statementResults[0].error.constraint).toBe('orders_user_id_fkey');
1217
- });
1218
- it('should limit results to maxStatements', async () => {
1219
- const { dryRunSqlFile } = await import('../tools/sql-tools.js');
1220
- const statements = Array(30).fill('SELECT 1;').join('\n');
1221
- fs.writeFileSync(testFile, statements);
1222
- // Mock BEGIN, 30 SELECTs, ROLLBACK
1223
- mockClient.query.mockResolvedValue({ rows: [], rowCount: 1 });
1224
- const result = await dryRunSqlFile({
1225
- filePath: testFile,
1226
- maxStatements: 10
1227
- });
1228
- expect(result.totalStatements).toBe(30);
1229
- expect(result.statementResults.length).toBe(10);
1230
- });
1231
- });
1232
- describe('allowMultipleStatements', () => {
1233
- it('should execute multiple statements and return results for each', async () => {
1234
- mockQuery
1235
- .mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 })
1236
- .mockResolvedValueOnce({ rows: [{ id: 2 }], rowCount: 1 })
1237
- .mockResolvedValueOnce({ rows: [{ count: 5 }], rowCount: 1 });
1238
- const result = await executeSql({
1239
- sql: 'INSERT INTO t VALUES (1); INSERT INTO t VALUES (2); SELECT COUNT(*) FROM t;',
1240
- allowMultipleStatements: true
1241
- });
1242
- expect(result.totalStatements).toBe(3);
1243
- expect(result.successCount).toBe(3);
1244
- expect(result.failureCount).toBe(0);
1245
- expect(result.results).toHaveLength(3);
1246
- });
1247
- it('should include line numbers in multi-statement results', async () => {
1248
- mockQuery
1249
- .mockResolvedValueOnce({ rows: [], rowCount: 0 })
1250
- .mockResolvedValueOnce({ rows: [], rowCount: 0 });
1251
- const result = await executeSql({
1252
- sql: `SELECT 1;
1253
-
1254
- SELECT 2;`,
1255
- allowMultipleStatements: true
1256
- });
1257
- expect(result.results[0].lineNumber).toBe(1);
1258
- expect(result.results[1].lineNumber).toBe(3);
1259
- });
1260
- it('should handle errors in multi-statement execution', async () => {
1261
- mockQuery
1262
- .mockResolvedValueOnce({ rows: [], rowCount: 0 })
1263
- .mockRejectedValueOnce(new Error('syntax error'))
1264
- .mockResolvedValueOnce({ rows: [], rowCount: 0 });
1265
- const result = await executeSql({
1266
- sql: 'SELECT 1; INVALID; SELECT 3;',
1267
- allowMultipleStatements: true
1268
- });
1269
- expect(result.totalStatements).toBe(3);
1270
- expect(result.successCount).toBe(2);
1271
- expect(result.failureCount).toBe(1);
1272
- expect(result.results[1].success).toBe(false);
1273
- expect(result.results[1].error).toContain('syntax error');
1274
- });
1275
- it('should not allow params with multiple statements', async () => {
1276
- await expect(executeSql({
1277
- sql: 'SELECT $1; SELECT $2;',
1278
- params: [1, 2],
1279
- allowMultipleStatements: true
1280
- })).rejects.toThrow('params not supported with allowMultipleStatements');
1281
- });
1282
- it('should skip empty statements and comments', async () => {
1283
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
1284
- const result = await executeSql({
1285
- sql: `
1286
- -- Just a comment
1287
- SELECT 1;
1288
- /* Block comment */
1289
- SELECT 2;
1290
- `,
1291
- allowMultipleStatements: true
1292
- });
1293
- expect(result.totalStatements).toBe(2);
1294
- });
1295
- it('should handle dollar-quoted strings in multi-statement', async () => {
1296
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
1297
- const result = await executeSql({
1298
- sql: `SELECT $$has ; semicolon$$; SELECT 2;`,
1299
- allowMultipleStatements: true
1300
- });
1301
- expect(result.totalStatements).toBe(2);
1302
- });
1303
- });
1304
- describe('mutationPreview', () => {
1305
- it('should require sql parameter', async () => {
1306
- await expect(mutationPreview({ sql: '' }))
1307
- .rejects.toThrow('sql parameter is required');
1308
- });
1309
- it('should only accept INSERT, UPDATE, DELETE statements', async () => {
1310
- await expect(mutationPreview({ sql: 'SELECT * FROM users' }))
1311
- .rejects.toThrow('SQL must be an INSERT, UPDATE, or DELETE statement');
1312
- });
1313
- it('should preview DELETE with WHERE clause', async () => {
1314
- mockQuery
1315
- .mockResolvedValueOnce({
1316
- rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 10 } }] }]
1317
- }) // EXPLAIN
1318
- .mockResolvedValueOnce({
1319
- rows: [{ id: 1 }, { id: 2 }]
1320
- }); // SELECT sample
1321
- const result = await mutationPreview({
1322
- sql: "DELETE FROM users WHERE status = 'inactive'"
1323
- });
1324
- expect(result.mutationType).toBe('DELETE');
1325
- expect(result.estimatedRowsAffected).toBe(10);
1326
- expect(result.sampleAffectedRows).toHaveLength(2);
1327
- expect(result.targetTable).toBe('users');
1328
- expect(result.whereClause).toBe("status = 'inactive'");
1329
- });
1330
- it('should preview UPDATE with WHERE clause', async () => {
1331
- mockQuery
1332
- .mockResolvedValueOnce({
1333
- rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 5 } }] }]
1334
- })
1335
- .mockResolvedValueOnce({
1336
- rows: [{ id: 1, name: 'Test' }]
1337
- });
1338
- const result = await mutationPreview({
1339
- sql: "UPDATE users SET name = 'Updated' WHERE id = 1"
1340
- });
1341
- expect(result.mutationType).toBe('UPDATE');
1342
- expect(result.targetTable).toBe('users');
1343
- });
1344
- it('should warn when no WHERE clause is present', async () => {
1345
- mockQuery
1346
- .mockResolvedValueOnce({
1347
- rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1000 } }] }]
1348
- })
1349
- .mockResolvedValueOnce({ rows: [] })
1350
- .mockResolvedValueOnce({ rows: [{ cnt: '1000' }] });
1351
- const result = await mutationPreview({
1352
- sql: 'DELETE FROM users'
1353
- });
1354
- expect(result.warning).toContain('ALL rows');
1355
- });
1356
- it('should handle INSERT preview', async () => {
1357
- // For INSERT, only EXPLAIN is called
1358
- mockQuery.mockResolvedValueOnce({
1359
- rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1 } }] }]
1360
- });
1361
- const result = await mutationPreview({
1362
- sql: "INSERT INTO users (name) VALUES ('test')"
1363
- });
1364
- expect(result.mutationType).toBe('INSERT');
1365
- expect(result.warning).toContain('INSERT preview cannot show affected rows');
1366
- expect(result.sampleAffectedRows).toHaveLength(0);
1367
- expect(result.estimatedRowsAffected).toBe(1);
1368
- });
1369
- it('should limit sample size', async () => {
1370
- mockQuery
1371
- .mockResolvedValueOnce({
1372
- rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 100 } }] }]
1373
- })
1374
- .mockResolvedValueOnce({ rows: Array(20).fill({ id: 1 }) });
1375
- const result = await mutationPreview({
1376
- sql: 'DELETE FROM users WHERE active = false',
1377
- sampleSize: 50 // Should be capped at 20
1378
- });
1379
- // The query should use LIMIT 20 (max)
1380
- expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('LIMIT 20'));
1381
- });
1382
- });
1383
- describe('batchExecute', () => {
1384
- it('should require queries parameter', async () => {
1385
- await expect(batchExecute({ queries: null }))
1386
- .rejects.toThrow('queries parameter is required');
1387
- });
1388
- it('should reject empty queries array', async () => {
1389
- await expect(batchExecute({ queries: [] }))
1390
- .rejects.toThrow('queries array cannot be empty');
1391
- });
1392
- it('should limit to 20 queries', async () => {
1393
- const queries = Array(21).fill(null).map((_, i) => ({
1394
- name: `q${i}`,
1395
- sql: 'SELECT 1'
1396
- }));
1397
- await expect(batchExecute({ queries }))
1398
- .rejects.toThrow('Maximum 20 queries allowed');
1399
- });
1400
- it('should require unique query names', async () => {
1401
- await expect(batchExecute({
1402
- queries: [
1403
- { name: 'same', sql: 'SELECT 1' },
1404
- { name: 'same', sql: 'SELECT 2' }
1405
- ]
1406
- })).rejects.toThrow('Duplicate query name');
1407
- });
1408
- it('should require name for each query', async () => {
1409
- await expect(batchExecute({
1410
- queries: [
1411
- { name: '', sql: 'SELECT 1' }
1412
- ]
1413
- })).rejects.toThrow('Each query must have a name');
1414
- });
1415
- it('should require sql for each query', async () => {
1416
- await expect(batchExecute({
1417
- queries: [
1418
- { name: 'test', sql: '' }
1419
- ]
1420
- })).rejects.toThrow('must have sql');
1421
- });
1422
- it('should execute multiple queries in parallel', async () => {
1423
- // For parallel tests, use a consistent mock value - order isn't guaranteed
1424
- mockQuery.mockResolvedValue({ rows: [{ value: 1 }], rowCount: 1 });
1425
- const result = await batchExecute({
1426
- queries: [
1427
- { name: 'count', sql: 'SELECT COUNT(*) FROM users' },
1428
- { name: 'sum', sql: 'SELECT SUM(amount) FROM orders' },
1429
- { name: 'avg', sql: 'SELECT AVG(price) FROM products' }
1430
- ]
1431
- });
1432
- expect(result.totalQueries).toBe(3);
1433
- expect(result.successCount).toBe(3);
1434
- expect(result.failureCount).toBe(0);
1435
- expect(result.results.count.success).toBe(true);
1436
- expect(result.results.count.rows).toEqual([{ value: 1 }]);
1437
- expect(result.results.sum.success).toBe(true);
1438
- expect(result.results.avg.success).toBe(true);
1439
- expect(mockQuery).toHaveBeenCalledTimes(3);
1440
- });
1441
- it('should handle partial failures', async () => {
1442
- // Test with all queries succeeding first - then test single failure case
1443
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
1444
- const successResult = await batchExecute({
1445
- queries: [
1446
- { name: 'q1', sql: 'SELECT 1' },
1447
- { name: 'q2', sql: 'SELECT 2' }
1448
- ]
1449
- });
1450
- expect(successResult.successCount).toBe(2);
1451
- expect(successResult.failureCount).toBe(0);
1452
- });
1453
- it('should capture query errors correctly', async () => {
1454
- // Test single query failure
1455
- mockQuery.mockRejectedValue(new Error('Table not found'));
1456
- const result = await batchExecute({
1457
- queries: [
1458
- { name: 'fail', sql: 'SELECT * FROM nonexistent' }
1459
- ]
1460
- });
1461
- expect(result.successCount).toBe(0);
1462
- expect(result.failureCount).toBe(1);
1463
- expect(result.results.fail.success).toBe(false);
1464
- expect(result.results.fail.error).toContain('Table not found');
1465
- });
1466
- it('should track execution time for each query', async () => {
1467
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
1468
- const result = await batchExecute({
1469
- queries: [
1470
- { name: 'q1', sql: 'SELECT 1' }
1471
- ]
1472
- });
1473
- expect(result.results.q1.executionTimeMs).toBeDefined();
1474
- expect(typeof result.results.q1.executionTimeMs).toBe('number');
1475
- expect(result.totalExecutionTimeMs).toBeDefined();
1476
- });
1477
- it('should support query parameters', async () => {
1478
- mockQuery.mockResolvedValue({ rows: [{ id: 1 }], rowCount: 1 });
1479
- await batchExecute({
1480
- queries: [
1481
- { name: 'q1', sql: 'SELECT * FROM users WHERE id = $1', params: [123] }
1482
- ]
1483
- });
1484
- expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM users WHERE id = $1', [123]);
1485
- });
1486
- });
1487
- describe('Transaction Control', () => {
1488
- it('should begin a transaction and return transactionId', async () => {
1489
- mockBeginTransaction.mockResolvedValue({
1490
- transactionId: 'test-tx-id',
1491
- server: 'test-server',
1492
- database: 'test-db',
1493
- schema: 'public',
1494
- startedAt: new Date()
1495
- });
1496
- const result = await beginTransaction();
1497
- expect(result.transactionId).toBe('test-tx-id');
1498
- expect(result.status).toBe('started');
1499
- expect(result.message).toContain('test-tx-id');
1500
- });
1501
- it('should commit a transaction', async () => {
1502
- mockCommitTransaction.mockResolvedValue(undefined);
1503
- const result = await commitTransaction({ transactionId: 'test-tx-id' });
1504
- expect(result.status).toBe('committed');
1505
- expect(mockCommitTransaction).toHaveBeenCalledWith('test-tx-id');
1506
- });
1507
- it('should require transactionId for commit', async () => {
1508
- await expect(commitTransaction({ transactionId: '' }))
1509
- .rejects.toThrow('transactionId is required');
1510
- });
1511
- it('should rollback a transaction', async () => {
1512
- mockRollbackTransaction.mockResolvedValue(undefined);
1513
- const result = await rollbackTransaction({ transactionId: 'test-tx-id' });
1514
- expect(result.status).toBe('rolled_back');
1515
- expect(mockRollbackTransaction).toHaveBeenCalledWith('test-tx-id');
1516
- });
1517
- it('should require transactionId for rollback', async () => {
1518
- await expect(rollbackTransaction({ transactionId: '' }))
1519
- .rejects.toThrow('transactionId is required');
1520
- });
1521
- it('should execute query within transaction', async () => {
1522
- mockQueryInTransaction.mockResolvedValue({
1523
- rows: [{ id: 1 }],
1524
- rowCount: 1,
1525
- fields: [{ name: 'id' }]
1526
- });
1527
- const result = await executeSql({
1528
- sql: 'SELECT * FROM users',
1529
- transactionId: 'test-tx-id'
1530
- });
1531
- expect(mockQueryInTransaction).toHaveBeenCalledWith('test-tx-id', 'SELECT * FROM users', undefined);
1532
- expect(result.rows).toEqual([{ id: 1 }]);
1533
- });
1534
- it('should use transactionId with multi-statement execution', async () => {
1535
- mockQueryInTransaction
1536
- .mockResolvedValueOnce({ rows: [], rowCount: 0 })
1537
- .mockResolvedValueOnce({ rows: [], rowCount: 0 });
1538
- const result = await executeSql({
1539
- sql: 'INSERT INTO t VALUES (1); SELECT * FROM t;',
1540
- allowMultipleStatements: true,
1541
- transactionId: 'test-tx-id'
1542
- });
1543
- expect(mockQueryInTransaction).toHaveBeenCalledTimes(2);
1544
- expect(result.totalStatements).toBe(2);
1545
- });
1546
- });
1547
- describe('getConnectionContext', () => {
1548
- it('should return current connection context', () => {
1549
- const context = getConnectionContext();
1550
- expect(context).toEqual({
1551
- server: 'test-server',
1552
- database: 'test-db',
1553
- schema: 'public'
1554
- });
1555
- });
1556
- });
1557
- describe('includeSchemaHint', () => {
1558
- it('should include schema hints when requested', async () => {
1559
- // Schema hint queries run FIRST (columns, pk, fk, count), then main query
1560
- mockQuery
1561
- // 1. Mock for columns query (schema hint - runs first)
1562
- .mockResolvedValueOnce({
1563
- rows: [
1564
- { name: 'id', type: 'integer', nullable: false },
1565
- { name: 'name', type: 'text', nullable: true }
1566
- ]
1567
- })
1568
- // 2. Mock for primary key query (schema hint)
1569
- .mockResolvedValueOnce({
1570
- rows: [{ column_name: 'id' }]
1571
- })
1572
- // 3. Mock for foreign keys query (schema hint)
1573
- .mockResolvedValueOnce({ rows: [] })
1574
- // 4. Mock for row count estimate (schema hint)
1575
- .mockResolvedValueOnce({
1576
- rows: [{ estimate: 1000 }]
1577
- })
1578
- // 5. Mock for main query (runs last)
1579
- .mockResolvedValueOnce({
1580
- rows: [{ id: 1 }],
1581
- rowCount: 1,
1582
- fields: [{ name: 'id' }]
1583
- });
1584
- const result = await executeSql({
1585
- sql: 'SELECT * FROM users',
1586
- includeSchemaHint: true
1587
- });
1588
- expect(result.schemaHint).toBeDefined();
1589
- expect(result.schemaHint.tables).toHaveLength(1);
1590
- expect(result.schemaHint.tables[0].table).toBe('users');
1591
- expect(result.schemaHint.tables[0].columns).toHaveLength(2);
1592
- expect(result.schemaHint.tables[0].primaryKey).toContain('id');
1593
- });
1594
- it('should extract tables from JOIN queries', async () => {
1595
- mockQuery.mockResolvedValue({
1596
- rows: [],
1597
- rowCount: 0,
1598
- fields: []
1599
- });
1600
- await executeSql({
1601
- sql: 'SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id',
1602
- includeSchemaHint: true
1603
- });
1604
- // Should query schema info for both tables
1605
- expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('information_schema.columns'), ['public', 'orders']);
1606
- });
1607
- it('should handle schema-qualified table names', async () => {
1608
- mockQuery.mockResolvedValue({
1609
- rows: [],
1610
- rowCount: 0,
1611
- fields: []
1612
- });
1613
- await executeSql({
1614
- sql: 'SELECT * FROM myschema.users',
1615
- includeSchemaHint: true
1616
- });
1617
- expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('information_schema.columns'), ['myschema', 'users']);
1618
- });
1619
- it('should not include schema hints when not requested', async () => {
1620
- mockQuery.mockResolvedValue({
1621
- rows: [],
1622
- rowCount: 0,
1623
- fields: []
1624
- });
1625
- const result = await executeSql({
1626
- sql: 'SELECT * FROM users',
1627
- includeSchemaHint: false
1628
- });
1629
- expect(result.schemaHint).toBeUndefined();
1630
- });
1631
- });
1632
- describe('SQL Parsing Safety (ReDoS Prevention)', () => {
1633
- // These tests ensure the regex patterns don't cause infinite loops or excessive backtracking
1634
- it('should handle deeply nested comments without hanging', async () => {
1635
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1636
- const nestedComments = '/* outer /* inner */ still outer */ SELECT 1;';
1637
- const result = await executeSql({
1638
- sql: nestedComments,
1639
- allowMultipleStatements: true
1640
- });
1641
- // Should complete quickly without hanging
1642
- expect(result).toBeDefined();
1643
- });
1644
- it('should handle many semicolons without performance issues', async () => {
1645
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1646
- // Create string with many semicolons
1647
- const manySemicolons = 'SELECT 1' + ';'.repeat(100);
1648
- const startTime = Date.now();
1649
- const result = await executeSql({
1650
- sql: manySemicolons,
1651
- allowMultipleStatements: true
1652
- });
1653
- const duration = Date.now() - startTime;
1654
- // Should complete in under 1 second
1655
- expect(duration).toBeLessThan(1000);
1656
- });
1657
- it('should handle long strings of repeated characters safely', async () => {
1658
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1659
- // Potential ReDoS pattern - many repeated characters
1660
- const longRepeat = 'SELECT ' + 'a'.repeat(10000);
1661
- const startTime = Date.now();
1662
- await expect(executeSql({
1663
- sql: longRepeat,
1664
- allowMultipleStatements: true
1665
- })).resolves.toBeDefined();
1666
- const duration = Date.now() - startTime;
1667
- expect(duration).toBeLessThan(2000);
1668
- });
1669
- it('should handle alternating quotes safely', async () => {
1670
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1671
- // Alternating quotes that could confuse parsers
1672
- const alternating = `SELECT '"'"'"'"'"'"'";`;
1673
- const result = await executeSql({
1674
- sql: alternating,
1675
- allowMultipleStatements: true
1676
- });
1677
- expect(result).toBeDefined();
1678
- });
1679
- it('should handle unclosed dollar quotes gracefully', async () => {
1680
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1681
- // Unclosed dollar quote
1682
- const unclosed = 'SELECT $tag$never closed';
1683
- const startTime = Date.now();
1684
- const result = await executeSql({
1685
- sql: unclosed,
1686
- allowMultipleStatements: true
1687
- });
1688
- const duration = Date.now() - startTime;
1689
- expect(duration).toBeLessThan(1000);
1690
- expect(result.totalStatements).toBe(1);
1691
- });
1692
- it('should handle pathological backtracking patterns in comments', async () => {
1693
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1694
- // Pattern that could cause exponential backtracking
1695
- const pathological = '/*' + '*'.repeat(1000) + '/ SELECT 1;';
1696
- const startTime = Date.now();
1697
- const result = await executeSql({
1698
- sql: pathological,
1699
- allowMultipleStatements: true
1700
- });
1701
- const duration = Date.now() - startTime;
1702
- expect(duration).toBeLessThan(1000);
1703
- });
1704
- it('should handle very long line comments', async () => {
1705
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1706
- const longComment = '-- ' + 'x'.repeat(50000) + '\nSELECT 1;';
1707
- const startTime = Date.now();
1708
- const result = await executeSql({
1709
- sql: longComment,
1710
- allowMultipleStatements: true
1711
- });
1712
- const duration = Date.now() - startTime;
1713
- expect(duration).toBeLessThan(2000);
1714
- expect(result.totalStatements).toBe(1);
1715
- });
1716
- it('should handle multiple dollar-quote tags in sequence', async () => {
1717
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1718
- const multiDollar = `
1719
- SELECT $a$test$a$;
1720
- SELECT $b$test$b$;
1721
- SELECT $$test$$;
1722
- `;
1723
- const result = await executeSql({
1724
- sql: multiDollar,
1725
- allowMultipleStatements: true
1726
- });
1727
- expect(result.totalStatements).toBe(3);
1728
- });
1729
- it('should handle mixed quote styles without confusion', async () => {
1730
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1731
- const mixedQuotes = `
1732
- SELECT 'single "with" double';
1733
- SELECT "double 'with' single";
1734
- SELECT $$dollar 'with' all "kinds"$$;
1735
- `;
1736
- const result = await executeSql({
1737
- sql: mixedQuotes,
1738
- allowMultipleStatements: true
1739
- });
1740
- expect(result.totalStatements).toBe(3);
1741
- });
1742
- it('should handle escaped quotes correctly', async () => {
1743
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1744
- const escapedQuotes = `SELECT 'it''s escaped'; SELECT "double""quote";`;
1745
- const result = await executeSql({
1746
- sql: escapedQuotes,
1747
- allowMultipleStatements: true
1748
- });
1749
- expect(result.totalStatements).toBe(2);
1750
- });
1751
- });
1752
- describe('Table Extraction Safety', () => {
1753
- it('should extract tables from complex JOIN chains', async () => {
1754
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1755
- await executeSql({
1756
- sql: `
1757
- SELECT * FROM t1
1758
- JOIN t2 ON t1.id = t2.t1_id
1759
- LEFT JOIN t3 ON t2.id = t3.t2_id
1760
- RIGHT JOIN t4 ON t3.id = t4.t3_id
1761
- INNER JOIN t5 ON t4.id = t5.t4_id
1762
- `,
1763
- includeSchemaHint: true
1764
- });
1765
- // Should have attempted to fetch schema for multiple tables
1766
- const calls = mockQuery.mock.calls;
1767
- const schemaCalls = calls.filter((c) => c[0] && c[0].includes('information_schema'));
1768
- expect(schemaCalls.length).toBeGreaterThanOrEqual(1);
1769
- });
1770
- it('should handle subqueries without extracting them as tables', async () => {
1771
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1772
- await executeSql({
1773
- sql: `SELECT * FROM (SELECT 1) AS subq`,
1774
- includeSchemaHint: true
1775
- });
1776
- // Should not try to look up "subq" as a real table
1777
- expect(mockQuery).toHaveBeenCalled();
1778
- });
1779
- it('should handle very long table names', async () => {
1780
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1781
- const longTableName = 'a'.repeat(63); // Max PostgreSQL identifier length
1782
- await executeSql({
1783
- sql: `SELECT * FROM ${longTableName}`,
1784
- includeSchemaHint: true
1785
- });
1786
- expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('information_schema'), ['public', longTableName]);
1787
- });
1788
- it('should handle quoted identifiers', async () => {
1789
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1790
- await executeSql({
1791
- sql: `SELECT * FROM "MyTable" JOIN "schema"."OtherTable" ON 1=1`,
1792
- includeSchemaHint: true
1793
- });
1794
- // Should extract both table names correctly
1795
- expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('information_schema'), expect.arrayContaining(['MyTable']));
1796
- });
1797
- });
1798
- describe('Edge Cases in Statement Splitting', () => {
1799
- it('should handle empty SQL gracefully', async () => {
1800
- await expect(executeSql({ sql: '' }))
1801
- .rejects.toThrow('cannot be empty');
1802
- });
1803
- it('should handle SQL with only whitespace', async () => {
1804
- await expect(executeSql({ sql: ' \n\t ' }))
1805
- .rejects.toThrow('cannot be empty');
1806
- });
1807
- it('should handle SQL with only comments', async () => {
1808
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1809
- const result = await executeSql({
1810
- sql: '-- just a comment\n/* block comment */',
1811
- allowMultipleStatements: true
1812
- });
1813
- expect(result.totalStatements).toBe(0);
1814
- });
1815
- it('should handle statement ending without semicolon', async () => {
1816
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1817
- const result = await executeSql({
1818
- sql: 'SELECT 1',
1819
- allowMultipleStatements: true
1820
- });
1821
- expect(result.totalStatements).toBe(1);
1822
- });
1823
- it('should handle multiple empty lines between statements', async () => {
1824
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1825
- const result = await executeSql({
1826
- sql: 'SELECT 1;\n\n\n\n\nSELECT 2;',
1827
- allowMultipleStatements: true
1828
- });
1829
- expect(result.totalStatements).toBe(2);
1830
- expect(result.results[0].lineNumber).toBe(1);
1831
- expect(result.results[1].lineNumber).toBe(6);
1832
- });
1833
- it('should handle carriage returns correctly', async () => {
1834
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1835
- const result = await executeSql({
1836
- sql: 'SELECT 1;\r\nSELECT 2;\r\nSELECT 3;',
1837
- allowMultipleStatements: true
1838
- });
1839
- expect(result.totalStatements).toBe(3);
1840
- });
1841
- it('should handle Unicode characters in statements', async () => {
1842
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1843
- const result = await executeSql({
1844
- sql: "SELECT '日本語'; SELECT 'émoji 🎉';",
1845
- allowMultipleStatements: true
1846
- });
1847
- expect(result.totalStatements).toBe(2);
1848
- });
1849
- it('should handle null bytes safely', async () => {
1850
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
1851
- // SQL with null byte should still parse
1852
- const withNull = 'SELECT 1;\x00SELECT 2;';
1853
- const result = await executeSql({
1854
- sql: withNull,
1855
- allowMultipleStatements: true
1856
- });
1857
- expect(result).toBeDefined();
1858
- });
1859
- });
1860
- describe('Mutation Preview Edge Cases', () => {
1861
- it('should handle UPDATE with multiple SET clauses', async () => {
1862
- mockQuery
1863
- .mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1 } }] }] })
1864
- .mockResolvedValueOnce({ rows: [] });
1865
- const result = await mutationPreview({
1866
- sql: "UPDATE users SET name = 'test', email = 'test@test.com', updated_at = NOW() WHERE id = 1"
1867
- });
1868
- expect(result.mutationType).toBe('UPDATE');
1869
- expect(result.targetTable).toBe('users');
1870
- expect(result.whereClause).toBe('id = 1');
1871
- });
1872
- it('should handle DELETE with complex WHERE clause', async () => {
1873
- mockQuery
1874
- .mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 5 } }] }] })
1875
- .mockResolvedValueOnce({ rows: [] });
1876
- const result = await mutationPreview({
1877
- sql: `DELETE FROM orders WHERE status = 'cancelled' AND created_at < '2024-01-01' OR amount = 0`
1878
- });
1879
- expect(result.mutationType).toBe('DELETE');
1880
- expect(result.whereClause).toContain('cancelled');
1881
- });
1882
- it('should handle schema-qualified table in UPDATE', async () => {
1883
- mockQuery
1884
- .mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1 } }] }] })
1885
- .mockResolvedValueOnce({ rows: [] });
1886
- const result = await mutationPreview({
1887
- sql: "UPDATE myschema.users SET name = 'test' WHERE id = 1"
1888
- });
1889
- expect(result.targetTable).toBe('myschema.users');
1890
- });
1891
- it('should handle quoted table names in DELETE', async () => {
1892
- mockQuery
1893
- .mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1 } }] }] })
1894
- .mockResolvedValueOnce({ rows: [] });
1895
- const result = await mutationPreview({
1896
- sql: `DELETE FROM "MyTable" WHERE id = 1`
1897
- });
1898
- expect(result.targetTable).toBe('MyTable');
1899
- });
1900
- it('should fallback to COUNT when EXPLAIN fails', async () => {
1901
- mockQuery
1902
- .mockRejectedValueOnce(new Error('EXPLAIN failed'))
1903
- .mockResolvedValueOnce({ rows: [] })
1904
- .mockResolvedValueOnce({ rows: [{ cnt: '42' }] });
1905
- const result = await mutationPreview({
1906
- sql: "DELETE FROM users WHERE status = 'old'"
1907
- });
1908
- expect(result.estimatedRowsAffected).toBe(42);
1909
- });
1910
- });
1911
- });
1912
- //# sourceMappingURL=sql-tools.test.js.map