@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.
- package/README.md +186 -10
- package/dist/db-manager/index.d.ts +7 -0
- package/dist/db-manager/index.d.ts.map +1 -0
- package/dist/db-manager/index.js +7 -0
- package/dist/db-manager/index.js.map +1 -0
- package/dist/db-manager/validation.d.ts +35 -0
- package/dist/db-manager/validation.d.ts.map +1 -0
- package/dist/db-manager/validation.js +54 -0
- package/dist/db-manager/validation.js.map +1 -0
- package/dist/db-manager.d.ts +175 -5
- package/dist/db-manager.d.ts.map +1 -1
- package/dist/db-manager.js +589 -26
- package/dist/db-manager.js.map +1 -1
- package/dist/index.js +141 -11
- package/dist/index.js.map +1 -1
- package/dist/tools/analysis-tools.d.ts.map +1 -1
- package/dist/tools/analysis-tools.js +53 -49
- package/dist/tools/analysis-tools.js.map +1 -1
- package/dist/tools/schema-tools.d.ts +40 -1
- package/dist/tools/schema-tools.d.ts.map +1 -1
- package/dist/tools/schema-tools.js +174 -92
- package/dist/tools/schema-tools.js.map +1 -1
- package/dist/tools/server-tools.d.ts +1 -0
- package/dist/tools/server-tools.d.ts.map +1 -1
- package/dist/tools/server-tools.js +10 -6
- package/dist/tools/server-tools.js.map +1 -1
- package/dist/tools/sql/utils/connection-utils.d.ts +79 -0
- package/dist/tools/sql/utils/connection-utils.d.ts.map +1 -0
- package/dist/tools/sql/utils/connection-utils.js +129 -0
- package/dist/tools/sql/utils/connection-utils.js.map +1 -0
- package/dist/tools/sql/utils/constants.d.ts +55 -0
- package/dist/tools/sql/utils/constants.d.ts.map +1 -0
- package/dist/tools/sql/utils/constants.js +55 -0
- package/dist/tools/sql/utils/constants.js.map +1 -0
- package/dist/tools/sql/utils/dry-run-utils.d.ts +31 -0
- package/dist/tools/sql/utils/dry-run-utils.d.ts.map +1 -0
- package/dist/tools/sql/utils/dry-run-utils.js +173 -0
- package/dist/tools/sql/utils/dry-run-utils.js.map +1 -0
- package/dist/tools/sql/utils/file-handler.d.ts +57 -0
- package/dist/tools/sql/utils/file-handler.d.ts.map +1 -0
- package/dist/tools/sql/utils/file-handler.js +150 -0
- package/dist/tools/sql/utils/file-handler.js.map +1 -0
- package/dist/tools/sql/utils/index.d.ts +12 -0
- package/dist/tools/sql/utils/index.d.ts.map +1 -0
- package/dist/tools/sql/utils/index.js +12 -0
- package/dist/tools/sql/utils/index.js.map +1 -0
- package/dist/tools/sql/utils/result-formatter.d.ts +94 -0
- package/dist/tools/sql/utils/result-formatter.d.ts.map +1 -0
- package/dist/tools/sql/utils/result-formatter.js +154 -0
- package/dist/tools/sql/utils/result-formatter.js.map +1 -0
- package/dist/tools/sql/utils/sql-parser.d.ts +125 -0
- package/dist/tools/sql/utils/sql-parser.d.ts.map +1 -0
- package/dist/tools/sql/utils/sql-parser.js +468 -0
- package/dist/tools/sql/utils/sql-parser.js.map +1 -0
- package/dist/tools/sql-tools.d.ts +21 -0
- package/dist/tools/sql-tools.d.ts.map +1 -1
- package/dist/tools/sql-tools.js +383 -532
- package/dist/tools/sql-tools.js.map +1 -1
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/retry.d.ts +1 -1
- package/dist/utils/retry.d.ts.map +1 -1
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/validation.d.ts +45 -9
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +335 -72
- package/dist/utils/validation.js.map +1 -1
- package/package.json +9 -2
- package/dist/__tests__/analysis-tools.test.d.ts +0 -2
- package/dist/__tests__/analysis-tools.test.d.ts.map +0 -1
- package/dist/__tests__/analysis-tools.test.js +0 -294
- package/dist/__tests__/analysis-tools.test.js.map +0 -1
- package/dist/__tests__/db-manager.test.d.ts +0 -2
- package/dist/__tests__/db-manager.test.d.ts.map +0 -1
- package/dist/__tests__/db-manager.test.js +0 -410
- package/dist/__tests__/db-manager.test.js.map +0 -1
- package/dist/__tests__/mcp-server.test.d.ts +0 -13
- package/dist/__tests__/mcp-server.test.d.ts.map +0 -1
- package/dist/__tests__/mcp-server.test.js +0 -146
- package/dist/__tests__/mcp-server.test.js.map +0 -1
- package/dist/__tests__/schema-tools.test.d.ts +0 -2
- package/dist/__tests__/schema-tools.test.d.ts.map +0 -1
- package/dist/__tests__/schema-tools.test.js +0 -171
- package/dist/__tests__/schema-tools.test.js.map +0 -1
- package/dist/__tests__/server-tools.test.d.ts +0 -2
- package/dist/__tests__/server-tools.test.d.ts.map +0 -1
- package/dist/__tests__/server-tools.test.js +0 -113
- package/dist/__tests__/server-tools.test.js.map +0 -1
- package/dist/__tests__/sql-tools.test.d.ts +0 -2
- package/dist/__tests__/sql-tools.test.d.ts.map +0 -1
- package/dist/__tests__/sql-tools.test.js +0 -1912
- package/dist/__tests__/sql-tools.test.js.map +0 -1
- package/dist/__tests__/validation.test.d.ts +0 -2
- package/dist/__tests__/validation.test.d.ts.map +0 -1
- package/dist/__tests__/validation.test.js +0 -203
- 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
|