@tejasanik/postgres-mcp-server 1.7.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -7
- package/dist/__tests__/db-manager.test.js +44 -0
- package/dist/__tests__/db-manager.test.js.map +1 -1
- package/dist/__tests__/sql-tools.test.js +746 -0
- package/dist/__tests__/sql-tools.test.js.map +1 -1
- package/dist/db-manager.d.ts +31 -1
- package/dist/db-manager.d.ts.map +1 -1
- package/dist/db-manager.js +110 -2
- package/dist/db-manager.js.map +1 -1
- package/dist/index.js +129 -11
- package/dist/index.js.map +1 -1
- package/dist/tools/server-tools.d.ts +2 -0
- package/dist/tools/server-tools.d.ts.map +1 -1
- package/dist/tools/server-tools.js +2 -1
- package/dist/tools/server-tools.js.map +1 -1
- package/dist/tools/sql-tools.d.ts +62 -2
- package/dist/tools/sql-tools.d.ts.map +1 -1
- package/dist/tools/sql-tools.js +530 -36
- package/dist/tools/sql-tools.js.map +1 -1
- package/dist/types.d.ts +133 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +60 -60
|
@@ -4,11 +4,25 @@ import * as fs from 'fs';
|
|
|
4
4
|
const mockQuery = jest.fn();
|
|
5
5
|
const mockGetClient = jest.fn();
|
|
6
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();
|
|
7
12
|
jest.unstable_mockModule('../db-manager.js', () => ({
|
|
8
13
|
getDbManager: jest.fn(() => ({
|
|
9
14
|
query: mockQuery,
|
|
10
15
|
getClient: mockGetClient,
|
|
11
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
|
+
}),
|
|
12
26
|
})),
|
|
13
27
|
resetDbManager: jest.fn(),
|
|
14
28
|
}));
|
|
@@ -16,15 +30,29 @@ jest.unstable_mockModule('../db-manager.js', () => ({
|
|
|
16
30
|
let executeSql;
|
|
17
31
|
let explainQuery;
|
|
18
32
|
let executeSqlFile;
|
|
33
|
+
let mutationPreview;
|
|
34
|
+
let batchExecute;
|
|
35
|
+
let beginTransaction;
|
|
36
|
+
let commitTransaction;
|
|
37
|
+
let rollbackTransaction;
|
|
38
|
+
let getConnectionContext;
|
|
19
39
|
beforeAll(async () => {
|
|
20
40
|
const module = await import('../tools/sql-tools.js');
|
|
21
41
|
executeSql = module.executeSql;
|
|
22
42
|
explainQuery = module.explainQuery;
|
|
23
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;
|
|
24
50
|
});
|
|
25
51
|
describe('SQL Tools', () => {
|
|
26
52
|
beforeEach(() => {
|
|
27
53
|
jest.clearAllMocks();
|
|
54
|
+
// Reset mock implementations to avoid leakage between tests
|
|
55
|
+
mockQuery.mockReset();
|
|
28
56
|
mockIsConnected.mockReturnValue(true);
|
|
29
57
|
});
|
|
30
58
|
describe('executeSql', () => {
|
|
@@ -448,6 +476,724 @@ describe('SQL Tools', () => {
|
|
|
448
476
|
}
|
|
449
477
|
expect(mockClient.release).toHaveBeenCalled();
|
|
450
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('allowMultipleStatements', () => {
|
|
520
|
+
it('should execute multiple statements and return results for each', async () => {
|
|
521
|
+
mockQuery
|
|
522
|
+
.mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 })
|
|
523
|
+
.mockResolvedValueOnce({ rows: [{ id: 2 }], rowCount: 1 })
|
|
524
|
+
.mockResolvedValueOnce({ rows: [{ count: 5 }], rowCount: 1 });
|
|
525
|
+
const result = await executeSql({
|
|
526
|
+
sql: 'INSERT INTO t VALUES (1); INSERT INTO t VALUES (2); SELECT COUNT(*) FROM t;',
|
|
527
|
+
allowMultipleStatements: true
|
|
528
|
+
});
|
|
529
|
+
expect(result.totalStatements).toBe(3);
|
|
530
|
+
expect(result.successCount).toBe(3);
|
|
531
|
+
expect(result.failureCount).toBe(0);
|
|
532
|
+
expect(result.results).toHaveLength(3);
|
|
533
|
+
});
|
|
534
|
+
it('should include line numbers in multi-statement results', async () => {
|
|
535
|
+
mockQuery
|
|
536
|
+
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
|
537
|
+
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
|
538
|
+
const result = await executeSql({
|
|
539
|
+
sql: `SELECT 1;
|
|
540
|
+
|
|
541
|
+
SELECT 2;`,
|
|
542
|
+
allowMultipleStatements: true
|
|
543
|
+
});
|
|
544
|
+
expect(result.results[0].lineNumber).toBe(1);
|
|
545
|
+
expect(result.results[1].lineNumber).toBe(3);
|
|
546
|
+
});
|
|
547
|
+
it('should handle errors in multi-statement execution', async () => {
|
|
548
|
+
mockQuery
|
|
549
|
+
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
|
550
|
+
.mockRejectedValueOnce(new Error('syntax error'))
|
|
551
|
+
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
|
552
|
+
const result = await executeSql({
|
|
553
|
+
sql: 'SELECT 1; INVALID; SELECT 3;',
|
|
554
|
+
allowMultipleStatements: true
|
|
555
|
+
});
|
|
556
|
+
expect(result.totalStatements).toBe(3);
|
|
557
|
+
expect(result.successCount).toBe(2);
|
|
558
|
+
expect(result.failureCount).toBe(1);
|
|
559
|
+
expect(result.results[1].success).toBe(false);
|
|
560
|
+
expect(result.results[1].error).toContain('syntax error');
|
|
561
|
+
});
|
|
562
|
+
it('should not allow params with multiple statements', async () => {
|
|
563
|
+
await expect(executeSql({
|
|
564
|
+
sql: 'SELECT $1; SELECT $2;',
|
|
565
|
+
params: [1, 2],
|
|
566
|
+
allowMultipleStatements: true
|
|
567
|
+
})).rejects.toThrow('params not supported with allowMultipleStatements');
|
|
568
|
+
});
|
|
569
|
+
it('should skip empty statements and comments', async () => {
|
|
570
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
|
|
571
|
+
const result = await executeSql({
|
|
572
|
+
sql: `
|
|
573
|
+
-- Just a comment
|
|
574
|
+
SELECT 1;
|
|
575
|
+
/* Block comment */
|
|
576
|
+
SELECT 2;
|
|
577
|
+
`,
|
|
578
|
+
allowMultipleStatements: true
|
|
579
|
+
});
|
|
580
|
+
expect(result.totalStatements).toBe(2);
|
|
581
|
+
});
|
|
582
|
+
it('should handle dollar-quoted strings in multi-statement', async () => {
|
|
583
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
|
|
584
|
+
const result = await executeSql({
|
|
585
|
+
sql: `SELECT $$has ; semicolon$$; SELECT 2;`,
|
|
586
|
+
allowMultipleStatements: true
|
|
587
|
+
});
|
|
588
|
+
expect(result.totalStatements).toBe(2);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
describe('mutationPreview', () => {
|
|
592
|
+
it('should require sql parameter', async () => {
|
|
593
|
+
await expect(mutationPreview({ sql: '' }))
|
|
594
|
+
.rejects.toThrow('sql parameter is required');
|
|
595
|
+
});
|
|
596
|
+
it('should only accept INSERT, UPDATE, DELETE statements', async () => {
|
|
597
|
+
await expect(mutationPreview({ sql: 'SELECT * FROM users' }))
|
|
598
|
+
.rejects.toThrow('SQL must be an INSERT, UPDATE, or DELETE statement');
|
|
599
|
+
});
|
|
600
|
+
it('should preview DELETE with WHERE clause', async () => {
|
|
601
|
+
mockQuery
|
|
602
|
+
.mockResolvedValueOnce({
|
|
603
|
+
rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 10 } }] }]
|
|
604
|
+
}) // EXPLAIN
|
|
605
|
+
.mockResolvedValueOnce({
|
|
606
|
+
rows: [{ id: 1 }, { id: 2 }]
|
|
607
|
+
}); // SELECT sample
|
|
608
|
+
const result = await mutationPreview({
|
|
609
|
+
sql: "DELETE FROM users WHERE status = 'inactive'"
|
|
610
|
+
});
|
|
611
|
+
expect(result.mutationType).toBe('DELETE');
|
|
612
|
+
expect(result.estimatedRowsAffected).toBe(10);
|
|
613
|
+
expect(result.sampleAffectedRows).toHaveLength(2);
|
|
614
|
+
expect(result.targetTable).toBe('users');
|
|
615
|
+
expect(result.whereClause).toBe("status = 'inactive'");
|
|
616
|
+
});
|
|
617
|
+
it('should preview UPDATE with WHERE clause', async () => {
|
|
618
|
+
mockQuery
|
|
619
|
+
.mockResolvedValueOnce({
|
|
620
|
+
rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 5 } }] }]
|
|
621
|
+
})
|
|
622
|
+
.mockResolvedValueOnce({
|
|
623
|
+
rows: [{ id: 1, name: 'Test' }]
|
|
624
|
+
});
|
|
625
|
+
const result = await mutationPreview({
|
|
626
|
+
sql: "UPDATE users SET name = 'Updated' WHERE id = 1"
|
|
627
|
+
});
|
|
628
|
+
expect(result.mutationType).toBe('UPDATE');
|
|
629
|
+
expect(result.targetTable).toBe('users');
|
|
630
|
+
});
|
|
631
|
+
it('should warn when no WHERE clause is present', async () => {
|
|
632
|
+
mockQuery
|
|
633
|
+
.mockResolvedValueOnce({
|
|
634
|
+
rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1000 } }] }]
|
|
635
|
+
})
|
|
636
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
637
|
+
.mockResolvedValueOnce({ rows: [{ cnt: '1000' }] });
|
|
638
|
+
const result = await mutationPreview({
|
|
639
|
+
sql: 'DELETE FROM users'
|
|
640
|
+
});
|
|
641
|
+
expect(result.warning).toContain('ALL rows');
|
|
642
|
+
});
|
|
643
|
+
it('should handle INSERT preview', async () => {
|
|
644
|
+
// For INSERT, only EXPLAIN is called
|
|
645
|
+
mockQuery.mockResolvedValueOnce({
|
|
646
|
+
rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1 } }] }]
|
|
647
|
+
});
|
|
648
|
+
const result = await mutationPreview({
|
|
649
|
+
sql: "INSERT INTO users (name) VALUES ('test')"
|
|
650
|
+
});
|
|
651
|
+
expect(result.mutationType).toBe('INSERT');
|
|
652
|
+
expect(result.warning).toContain('INSERT preview cannot show affected rows');
|
|
653
|
+
expect(result.sampleAffectedRows).toHaveLength(0);
|
|
654
|
+
expect(result.estimatedRowsAffected).toBe(1);
|
|
655
|
+
});
|
|
656
|
+
it('should limit sample size', async () => {
|
|
657
|
+
mockQuery
|
|
658
|
+
.mockResolvedValueOnce({
|
|
659
|
+
rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 100 } }] }]
|
|
660
|
+
})
|
|
661
|
+
.mockResolvedValueOnce({ rows: Array(20).fill({ id: 1 }) });
|
|
662
|
+
const result = await mutationPreview({
|
|
663
|
+
sql: 'DELETE FROM users WHERE active = false',
|
|
664
|
+
sampleSize: 50 // Should be capped at 20
|
|
665
|
+
});
|
|
666
|
+
// The query should use LIMIT 20 (max)
|
|
667
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('LIMIT 20'));
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
describe('batchExecute', () => {
|
|
671
|
+
it('should require queries parameter', async () => {
|
|
672
|
+
await expect(batchExecute({ queries: null }))
|
|
673
|
+
.rejects.toThrow('queries parameter is required');
|
|
674
|
+
});
|
|
675
|
+
it('should reject empty queries array', async () => {
|
|
676
|
+
await expect(batchExecute({ queries: [] }))
|
|
677
|
+
.rejects.toThrow('queries array cannot be empty');
|
|
678
|
+
});
|
|
679
|
+
it('should limit to 20 queries', async () => {
|
|
680
|
+
const queries = Array(21).fill(null).map((_, i) => ({
|
|
681
|
+
name: `q${i}`,
|
|
682
|
+
sql: 'SELECT 1'
|
|
683
|
+
}));
|
|
684
|
+
await expect(batchExecute({ queries }))
|
|
685
|
+
.rejects.toThrow('Maximum 20 queries allowed');
|
|
686
|
+
});
|
|
687
|
+
it('should require unique query names', async () => {
|
|
688
|
+
await expect(batchExecute({
|
|
689
|
+
queries: [
|
|
690
|
+
{ name: 'same', sql: 'SELECT 1' },
|
|
691
|
+
{ name: 'same', sql: 'SELECT 2' }
|
|
692
|
+
]
|
|
693
|
+
})).rejects.toThrow('Duplicate query name');
|
|
694
|
+
});
|
|
695
|
+
it('should require name for each query', async () => {
|
|
696
|
+
await expect(batchExecute({
|
|
697
|
+
queries: [
|
|
698
|
+
{ name: '', sql: 'SELECT 1' }
|
|
699
|
+
]
|
|
700
|
+
})).rejects.toThrow('Each query must have a name');
|
|
701
|
+
});
|
|
702
|
+
it('should require sql for each query', async () => {
|
|
703
|
+
await expect(batchExecute({
|
|
704
|
+
queries: [
|
|
705
|
+
{ name: 'test', sql: '' }
|
|
706
|
+
]
|
|
707
|
+
})).rejects.toThrow('must have sql');
|
|
708
|
+
});
|
|
709
|
+
it('should execute multiple queries in parallel', async () => {
|
|
710
|
+
// For parallel tests, use a consistent mock value - order isn't guaranteed
|
|
711
|
+
mockQuery.mockResolvedValue({ rows: [{ value: 1 }], rowCount: 1 });
|
|
712
|
+
const result = await batchExecute({
|
|
713
|
+
queries: [
|
|
714
|
+
{ name: 'count', sql: 'SELECT COUNT(*) FROM users' },
|
|
715
|
+
{ name: 'sum', sql: 'SELECT SUM(amount) FROM orders' },
|
|
716
|
+
{ name: 'avg', sql: 'SELECT AVG(price) FROM products' }
|
|
717
|
+
]
|
|
718
|
+
});
|
|
719
|
+
expect(result.totalQueries).toBe(3);
|
|
720
|
+
expect(result.successCount).toBe(3);
|
|
721
|
+
expect(result.failureCount).toBe(0);
|
|
722
|
+
expect(result.results.count.success).toBe(true);
|
|
723
|
+
expect(result.results.count.rows).toEqual([{ value: 1 }]);
|
|
724
|
+
expect(result.results.sum.success).toBe(true);
|
|
725
|
+
expect(result.results.avg.success).toBe(true);
|
|
726
|
+
expect(mockQuery).toHaveBeenCalledTimes(3);
|
|
727
|
+
});
|
|
728
|
+
it('should handle partial failures', async () => {
|
|
729
|
+
// Test with all queries succeeding first - then test single failure case
|
|
730
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
|
|
731
|
+
const successResult = await batchExecute({
|
|
732
|
+
queries: [
|
|
733
|
+
{ name: 'q1', sql: 'SELECT 1' },
|
|
734
|
+
{ name: 'q2', sql: 'SELECT 2' }
|
|
735
|
+
]
|
|
736
|
+
});
|
|
737
|
+
expect(successResult.successCount).toBe(2);
|
|
738
|
+
expect(successResult.failureCount).toBe(0);
|
|
739
|
+
});
|
|
740
|
+
it('should capture query errors correctly', async () => {
|
|
741
|
+
// Test single query failure
|
|
742
|
+
mockQuery.mockRejectedValue(new Error('Table not found'));
|
|
743
|
+
const result = await batchExecute({
|
|
744
|
+
queries: [
|
|
745
|
+
{ name: 'fail', sql: 'SELECT * FROM nonexistent' }
|
|
746
|
+
]
|
|
747
|
+
});
|
|
748
|
+
expect(result.successCount).toBe(0);
|
|
749
|
+
expect(result.failureCount).toBe(1);
|
|
750
|
+
expect(result.results.fail.success).toBe(false);
|
|
751
|
+
expect(result.results.fail.error).toContain('Table not found');
|
|
752
|
+
});
|
|
753
|
+
it('should track execution time for each query', async () => {
|
|
754
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
|
|
755
|
+
const result = await batchExecute({
|
|
756
|
+
queries: [
|
|
757
|
+
{ name: 'q1', sql: 'SELECT 1' }
|
|
758
|
+
]
|
|
759
|
+
});
|
|
760
|
+
expect(result.results.q1.executionTimeMs).toBeDefined();
|
|
761
|
+
expect(typeof result.results.q1.executionTimeMs).toBe('number');
|
|
762
|
+
expect(result.totalExecutionTimeMs).toBeDefined();
|
|
763
|
+
});
|
|
764
|
+
it('should support query parameters', async () => {
|
|
765
|
+
mockQuery.mockResolvedValue({ rows: [{ id: 1 }], rowCount: 1 });
|
|
766
|
+
await batchExecute({
|
|
767
|
+
queries: [
|
|
768
|
+
{ name: 'q1', sql: 'SELECT * FROM users WHERE id = $1', params: [123] }
|
|
769
|
+
]
|
|
770
|
+
});
|
|
771
|
+
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM users WHERE id = $1', [123]);
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
describe('Transaction Control', () => {
|
|
775
|
+
it('should begin a transaction and return transactionId', async () => {
|
|
776
|
+
mockBeginTransaction.mockResolvedValue({
|
|
777
|
+
transactionId: 'test-tx-id',
|
|
778
|
+
server: 'test-server',
|
|
779
|
+
database: 'test-db',
|
|
780
|
+
schema: 'public',
|
|
781
|
+
startedAt: new Date()
|
|
782
|
+
});
|
|
783
|
+
const result = await beginTransaction();
|
|
784
|
+
expect(result.transactionId).toBe('test-tx-id');
|
|
785
|
+
expect(result.status).toBe('started');
|
|
786
|
+
expect(result.message).toContain('test-tx-id');
|
|
787
|
+
});
|
|
788
|
+
it('should commit a transaction', async () => {
|
|
789
|
+
mockCommitTransaction.mockResolvedValue(undefined);
|
|
790
|
+
const result = await commitTransaction({ transactionId: 'test-tx-id' });
|
|
791
|
+
expect(result.status).toBe('committed');
|
|
792
|
+
expect(mockCommitTransaction).toHaveBeenCalledWith('test-tx-id');
|
|
793
|
+
});
|
|
794
|
+
it('should require transactionId for commit', async () => {
|
|
795
|
+
await expect(commitTransaction({ transactionId: '' }))
|
|
796
|
+
.rejects.toThrow('transactionId is required');
|
|
797
|
+
});
|
|
798
|
+
it('should rollback a transaction', async () => {
|
|
799
|
+
mockRollbackTransaction.mockResolvedValue(undefined);
|
|
800
|
+
const result = await rollbackTransaction({ transactionId: 'test-tx-id' });
|
|
801
|
+
expect(result.status).toBe('rolled_back');
|
|
802
|
+
expect(mockRollbackTransaction).toHaveBeenCalledWith('test-tx-id');
|
|
803
|
+
});
|
|
804
|
+
it('should require transactionId for rollback', async () => {
|
|
805
|
+
await expect(rollbackTransaction({ transactionId: '' }))
|
|
806
|
+
.rejects.toThrow('transactionId is required');
|
|
807
|
+
});
|
|
808
|
+
it('should execute query within transaction', async () => {
|
|
809
|
+
mockQueryInTransaction.mockResolvedValue({
|
|
810
|
+
rows: [{ id: 1 }],
|
|
811
|
+
rowCount: 1,
|
|
812
|
+
fields: [{ name: 'id' }]
|
|
813
|
+
});
|
|
814
|
+
const result = await executeSql({
|
|
815
|
+
sql: 'SELECT * FROM users',
|
|
816
|
+
transactionId: 'test-tx-id'
|
|
817
|
+
});
|
|
818
|
+
expect(mockQueryInTransaction).toHaveBeenCalledWith('test-tx-id', 'SELECT * FROM users', undefined);
|
|
819
|
+
expect(result.rows).toEqual([{ id: 1 }]);
|
|
820
|
+
});
|
|
821
|
+
it('should use transactionId with multi-statement execution', async () => {
|
|
822
|
+
mockQueryInTransaction
|
|
823
|
+
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
|
824
|
+
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
|
825
|
+
const result = await executeSql({
|
|
826
|
+
sql: 'INSERT INTO t VALUES (1); SELECT * FROM t;',
|
|
827
|
+
allowMultipleStatements: true,
|
|
828
|
+
transactionId: 'test-tx-id'
|
|
829
|
+
});
|
|
830
|
+
expect(mockQueryInTransaction).toHaveBeenCalledTimes(2);
|
|
831
|
+
expect(result.totalStatements).toBe(2);
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
describe('getConnectionContext', () => {
|
|
835
|
+
it('should return current connection context', () => {
|
|
836
|
+
const context = getConnectionContext();
|
|
837
|
+
expect(context).toEqual({
|
|
838
|
+
server: 'test-server',
|
|
839
|
+
database: 'test-db',
|
|
840
|
+
schema: 'public'
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
describe('includeSchemaHint', () => {
|
|
845
|
+
it('should include schema hints when requested', async () => {
|
|
846
|
+
// Schema hint queries run FIRST (columns, pk, fk, count), then main query
|
|
847
|
+
mockQuery
|
|
848
|
+
// 1. Mock for columns query (schema hint - runs first)
|
|
849
|
+
.mockResolvedValueOnce({
|
|
850
|
+
rows: [
|
|
851
|
+
{ name: 'id', type: 'integer', nullable: false },
|
|
852
|
+
{ name: 'name', type: 'text', nullable: true }
|
|
853
|
+
]
|
|
854
|
+
})
|
|
855
|
+
// 2. Mock for primary key query (schema hint)
|
|
856
|
+
.mockResolvedValueOnce({
|
|
857
|
+
rows: [{ column_name: 'id' }]
|
|
858
|
+
})
|
|
859
|
+
// 3. Mock for foreign keys query (schema hint)
|
|
860
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
861
|
+
// 4. Mock for row count estimate (schema hint)
|
|
862
|
+
.mockResolvedValueOnce({
|
|
863
|
+
rows: [{ estimate: 1000 }]
|
|
864
|
+
})
|
|
865
|
+
// 5. Mock for main query (runs last)
|
|
866
|
+
.mockResolvedValueOnce({
|
|
867
|
+
rows: [{ id: 1 }],
|
|
868
|
+
rowCount: 1,
|
|
869
|
+
fields: [{ name: 'id' }]
|
|
870
|
+
});
|
|
871
|
+
const result = await executeSql({
|
|
872
|
+
sql: 'SELECT * FROM users',
|
|
873
|
+
includeSchemaHint: true
|
|
874
|
+
});
|
|
875
|
+
expect(result.schemaHint).toBeDefined();
|
|
876
|
+
expect(result.schemaHint.tables).toHaveLength(1);
|
|
877
|
+
expect(result.schemaHint.tables[0].table).toBe('users');
|
|
878
|
+
expect(result.schemaHint.tables[0].columns).toHaveLength(2);
|
|
879
|
+
expect(result.schemaHint.tables[0].primaryKey).toContain('id');
|
|
880
|
+
});
|
|
881
|
+
it('should extract tables from JOIN queries', async () => {
|
|
882
|
+
mockQuery.mockResolvedValue({
|
|
883
|
+
rows: [],
|
|
884
|
+
rowCount: 0,
|
|
885
|
+
fields: []
|
|
886
|
+
});
|
|
887
|
+
await executeSql({
|
|
888
|
+
sql: 'SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id',
|
|
889
|
+
includeSchemaHint: true
|
|
890
|
+
});
|
|
891
|
+
// Should query schema info for both tables
|
|
892
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('information_schema.columns'), ['public', 'orders']);
|
|
893
|
+
});
|
|
894
|
+
it('should handle schema-qualified table names', async () => {
|
|
895
|
+
mockQuery.mockResolvedValue({
|
|
896
|
+
rows: [],
|
|
897
|
+
rowCount: 0,
|
|
898
|
+
fields: []
|
|
899
|
+
});
|
|
900
|
+
await executeSql({
|
|
901
|
+
sql: 'SELECT * FROM myschema.users',
|
|
902
|
+
includeSchemaHint: true
|
|
903
|
+
});
|
|
904
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('information_schema.columns'), ['myschema', 'users']);
|
|
905
|
+
});
|
|
906
|
+
it('should not include schema hints when not requested', async () => {
|
|
907
|
+
mockQuery.mockResolvedValue({
|
|
908
|
+
rows: [],
|
|
909
|
+
rowCount: 0,
|
|
910
|
+
fields: []
|
|
911
|
+
});
|
|
912
|
+
const result = await executeSql({
|
|
913
|
+
sql: 'SELECT * FROM users',
|
|
914
|
+
includeSchemaHint: false
|
|
915
|
+
});
|
|
916
|
+
expect(result.schemaHint).toBeUndefined();
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
describe('SQL Parsing Safety (ReDoS Prevention)', () => {
|
|
920
|
+
// These tests ensure the regex patterns don't cause infinite loops or excessive backtracking
|
|
921
|
+
it('should handle deeply nested comments without hanging', async () => {
|
|
922
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
923
|
+
const nestedComments = '/* outer /* inner */ still outer */ SELECT 1;';
|
|
924
|
+
const result = await executeSql({
|
|
925
|
+
sql: nestedComments,
|
|
926
|
+
allowMultipleStatements: true
|
|
927
|
+
});
|
|
928
|
+
// Should complete quickly without hanging
|
|
929
|
+
expect(result).toBeDefined();
|
|
930
|
+
});
|
|
931
|
+
it('should handle many semicolons without performance issues', async () => {
|
|
932
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
933
|
+
// Create string with many semicolons
|
|
934
|
+
const manySemicolons = 'SELECT 1' + ';'.repeat(100);
|
|
935
|
+
const startTime = Date.now();
|
|
936
|
+
const result = await executeSql({
|
|
937
|
+
sql: manySemicolons,
|
|
938
|
+
allowMultipleStatements: true
|
|
939
|
+
});
|
|
940
|
+
const duration = Date.now() - startTime;
|
|
941
|
+
// Should complete in under 1 second
|
|
942
|
+
expect(duration).toBeLessThan(1000);
|
|
943
|
+
});
|
|
944
|
+
it('should handle long strings of repeated characters safely', async () => {
|
|
945
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
946
|
+
// Potential ReDoS pattern - many repeated characters
|
|
947
|
+
const longRepeat = 'SELECT ' + 'a'.repeat(10000);
|
|
948
|
+
const startTime = Date.now();
|
|
949
|
+
await expect(executeSql({
|
|
950
|
+
sql: longRepeat,
|
|
951
|
+
allowMultipleStatements: true
|
|
952
|
+
})).resolves.toBeDefined();
|
|
953
|
+
const duration = Date.now() - startTime;
|
|
954
|
+
expect(duration).toBeLessThan(2000);
|
|
955
|
+
});
|
|
956
|
+
it('should handle alternating quotes safely', async () => {
|
|
957
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
958
|
+
// Alternating quotes that could confuse parsers
|
|
959
|
+
const alternating = `SELECT '"'"'"'"'"'"'";`;
|
|
960
|
+
const result = await executeSql({
|
|
961
|
+
sql: alternating,
|
|
962
|
+
allowMultipleStatements: true
|
|
963
|
+
});
|
|
964
|
+
expect(result).toBeDefined();
|
|
965
|
+
});
|
|
966
|
+
it('should handle unclosed dollar quotes gracefully', async () => {
|
|
967
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
968
|
+
// Unclosed dollar quote
|
|
969
|
+
const unclosed = 'SELECT $tag$never closed';
|
|
970
|
+
const startTime = Date.now();
|
|
971
|
+
const result = await executeSql({
|
|
972
|
+
sql: unclosed,
|
|
973
|
+
allowMultipleStatements: true
|
|
974
|
+
});
|
|
975
|
+
const duration = Date.now() - startTime;
|
|
976
|
+
expect(duration).toBeLessThan(1000);
|
|
977
|
+
expect(result.totalStatements).toBe(1);
|
|
978
|
+
});
|
|
979
|
+
it('should handle pathological backtracking patterns in comments', async () => {
|
|
980
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
981
|
+
// Pattern that could cause exponential backtracking
|
|
982
|
+
const pathological = '/*' + '*'.repeat(1000) + '/ SELECT 1;';
|
|
983
|
+
const startTime = Date.now();
|
|
984
|
+
const result = await executeSql({
|
|
985
|
+
sql: pathological,
|
|
986
|
+
allowMultipleStatements: true
|
|
987
|
+
});
|
|
988
|
+
const duration = Date.now() - startTime;
|
|
989
|
+
expect(duration).toBeLessThan(1000);
|
|
990
|
+
});
|
|
991
|
+
it('should handle very long line comments', async () => {
|
|
992
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
993
|
+
const longComment = '-- ' + 'x'.repeat(50000) + '\nSELECT 1;';
|
|
994
|
+
const startTime = Date.now();
|
|
995
|
+
const result = await executeSql({
|
|
996
|
+
sql: longComment,
|
|
997
|
+
allowMultipleStatements: true
|
|
998
|
+
});
|
|
999
|
+
const duration = Date.now() - startTime;
|
|
1000
|
+
expect(duration).toBeLessThan(2000);
|
|
1001
|
+
expect(result.totalStatements).toBe(1);
|
|
1002
|
+
});
|
|
1003
|
+
it('should handle multiple dollar-quote tags in sequence', async () => {
|
|
1004
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1005
|
+
const multiDollar = `
|
|
1006
|
+
SELECT $a$test$a$;
|
|
1007
|
+
SELECT $b$test$b$;
|
|
1008
|
+
SELECT $$test$$;
|
|
1009
|
+
`;
|
|
1010
|
+
const result = await executeSql({
|
|
1011
|
+
sql: multiDollar,
|
|
1012
|
+
allowMultipleStatements: true
|
|
1013
|
+
});
|
|
1014
|
+
expect(result.totalStatements).toBe(3);
|
|
1015
|
+
});
|
|
1016
|
+
it('should handle mixed quote styles without confusion', async () => {
|
|
1017
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1018
|
+
const mixedQuotes = `
|
|
1019
|
+
SELECT 'single "with" double';
|
|
1020
|
+
SELECT "double 'with' single";
|
|
1021
|
+
SELECT $$dollar 'with' all "kinds"$$;
|
|
1022
|
+
`;
|
|
1023
|
+
const result = await executeSql({
|
|
1024
|
+
sql: mixedQuotes,
|
|
1025
|
+
allowMultipleStatements: true
|
|
1026
|
+
});
|
|
1027
|
+
expect(result.totalStatements).toBe(3);
|
|
1028
|
+
});
|
|
1029
|
+
it('should handle escaped quotes correctly', async () => {
|
|
1030
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1031
|
+
const escapedQuotes = `SELECT 'it''s escaped'; SELECT "double""quote";`;
|
|
1032
|
+
const result = await executeSql({
|
|
1033
|
+
sql: escapedQuotes,
|
|
1034
|
+
allowMultipleStatements: true
|
|
1035
|
+
});
|
|
1036
|
+
expect(result.totalStatements).toBe(2);
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
describe('Table Extraction Safety', () => {
|
|
1040
|
+
it('should extract tables from complex JOIN chains', async () => {
|
|
1041
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1042
|
+
await executeSql({
|
|
1043
|
+
sql: `
|
|
1044
|
+
SELECT * FROM t1
|
|
1045
|
+
JOIN t2 ON t1.id = t2.t1_id
|
|
1046
|
+
LEFT JOIN t3 ON t2.id = t3.t2_id
|
|
1047
|
+
RIGHT JOIN t4 ON t3.id = t4.t3_id
|
|
1048
|
+
INNER JOIN t5 ON t4.id = t5.t4_id
|
|
1049
|
+
`,
|
|
1050
|
+
includeSchemaHint: true
|
|
1051
|
+
});
|
|
1052
|
+
// Should have attempted to fetch schema for multiple tables
|
|
1053
|
+
const calls = mockQuery.mock.calls;
|
|
1054
|
+
const schemaCalls = calls.filter((c) => c[0] && c[0].includes('information_schema'));
|
|
1055
|
+
expect(schemaCalls.length).toBeGreaterThanOrEqual(1);
|
|
1056
|
+
});
|
|
1057
|
+
it('should handle subqueries without extracting them as tables', async () => {
|
|
1058
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1059
|
+
await executeSql({
|
|
1060
|
+
sql: `SELECT * FROM (SELECT 1) AS subq`,
|
|
1061
|
+
includeSchemaHint: true
|
|
1062
|
+
});
|
|
1063
|
+
// Should not try to look up "subq" as a real table
|
|
1064
|
+
expect(mockQuery).toHaveBeenCalled();
|
|
1065
|
+
});
|
|
1066
|
+
it('should handle very long table names', async () => {
|
|
1067
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1068
|
+
const longTableName = 'a'.repeat(63); // Max PostgreSQL identifier length
|
|
1069
|
+
await executeSql({
|
|
1070
|
+
sql: `SELECT * FROM ${longTableName}`,
|
|
1071
|
+
includeSchemaHint: true
|
|
1072
|
+
});
|
|
1073
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('information_schema'), ['public', longTableName]);
|
|
1074
|
+
});
|
|
1075
|
+
it('should handle quoted identifiers', async () => {
|
|
1076
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1077
|
+
await executeSql({
|
|
1078
|
+
sql: `SELECT * FROM "MyTable" JOIN "schema"."OtherTable" ON 1=1`,
|
|
1079
|
+
includeSchemaHint: true
|
|
1080
|
+
});
|
|
1081
|
+
// Should extract both table names correctly
|
|
1082
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('information_schema'), expect.arrayContaining(['MyTable']));
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
describe('Edge Cases in Statement Splitting', () => {
|
|
1086
|
+
it('should handle empty SQL gracefully', async () => {
|
|
1087
|
+
await expect(executeSql({ sql: '' }))
|
|
1088
|
+
.rejects.toThrow('cannot be empty');
|
|
1089
|
+
});
|
|
1090
|
+
it('should handle SQL with only whitespace', async () => {
|
|
1091
|
+
await expect(executeSql({ sql: ' \n\t ' }))
|
|
1092
|
+
.rejects.toThrow('cannot be empty');
|
|
1093
|
+
});
|
|
1094
|
+
it('should handle SQL with only comments', async () => {
|
|
1095
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1096
|
+
const result = await executeSql({
|
|
1097
|
+
sql: '-- just a comment\n/* block comment */',
|
|
1098
|
+
allowMultipleStatements: true
|
|
1099
|
+
});
|
|
1100
|
+
expect(result.totalStatements).toBe(0);
|
|
1101
|
+
});
|
|
1102
|
+
it('should handle statement ending without semicolon', async () => {
|
|
1103
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1104
|
+
const result = await executeSql({
|
|
1105
|
+
sql: 'SELECT 1',
|
|
1106
|
+
allowMultipleStatements: true
|
|
1107
|
+
});
|
|
1108
|
+
expect(result.totalStatements).toBe(1);
|
|
1109
|
+
});
|
|
1110
|
+
it('should handle multiple empty lines between statements', async () => {
|
|
1111
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1112
|
+
const result = await executeSql({
|
|
1113
|
+
sql: 'SELECT 1;\n\n\n\n\nSELECT 2;',
|
|
1114
|
+
allowMultipleStatements: true
|
|
1115
|
+
});
|
|
1116
|
+
expect(result.totalStatements).toBe(2);
|
|
1117
|
+
expect(result.results[0].lineNumber).toBe(1);
|
|
1118
|
+
expect(result.results[1].lineNumber).toBe(6);
|
|
1119
|
+
});
|
|
1120
|
+
it('should handle carriage returns correctly', async () => {
|
|
1121
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1122
|
+
const result = await executeSql({
|
|
1123
|
+
sql: 'SELECT 1;\r\nSELECT 2;\r\nSELECT 3;',
|
|
1124
|
+
allowMultipleStatements: true
|
|
1125
|
+
});
|
|
1126
|
+
expect(result.totalStatements).toBe(3);
|
|
1127
|
+
});
|
|
1128
|
+
it('should handle Unicode characters in statements', async () => {
|
|
1129
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1130
|
+
const result = await executeSql({
|
|
1131
|
+
sql: "SELECT '日本語'; SELECT 'émoji 🎉';",
|
|
1132
|
+
allowMultipleStatements: true
|
|
1133
|
+
});
|
|
1134
|
+
expect(result.totalStatements).toBe(2);
|
|
1135
|
+
});
|
|
1136
|
+
it('should handle null bytes safely', async () => {
|
|
1137
|
+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0, fields: [] });
|
|
1138
|
+
// SQL with null byte should still parse
|
|
1139
|
+
const withNull = 'SELECT 1;\x00SELECT 2;';
|
|
1140
|
+
const result = await executeSql({
|
|
1141
|
+
sql: withNull,
|
|
1142
|
+
allowMultipleStatements: true
|
|
1143
|
+
});
|
|
1144
|
+
expect(result).toBeDefined();
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
describe('Mutation Preview Edge Cases', () => {
|
|
1148
|
+
it('should handle UPDATE with multiple SET clauses', async () => {
|
|
1149
|
+
mockQuery
|
|
1150
|
+
.mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1 } }] }] })
|
|
1151
|
+
.mockResolvedValueOnce({ rows: [] });
|
|
1152
|
+
const result = await mutationPreview({
|
|
1153
|
+
sql: "UPDATE users SET name = 'test', email = 'test@test.com', updated_at = NOW() WHERE id = 1"
|
|
1154
|
+
});
|
|
1155
|
+
expect(result.mutationType).toBe('UPDATE');
|
|
1156
|
+
expect(result.targetTable).toBe('users');
|
|
1157
|
+
expect(result.whereClause).toBe('id = 1');
|
|
1158
|
+
});
|
|
1159
|
+
it('should handle DELETE with complex WHERE clause', async () => {
|
|
1160
|
+
mockQuery
|
|
1161
|
+
.mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 5 } }] }] })
|
|
1162
|
+
.mockResolvedValueOnce({ rows: [] });
|
|
1163
|
+
const result = await mutationPreview({
|
|
1164
|
+
sql: `DELETE FROM orders WHERE status = 'cancelled' AND created_at < '2024-01-01' OR amount = 0`
|
|
1165
|
+
});
|
|
1166
|
+
expect(result.mutationType).toBe('DELETE');
|
|
1167
|
+
expect(result.whereClause).toContain('cancelled');
|
|
1168
|
+
});
|
|
1169
|
+
it('should handle schema-qualified table in UPDATE', async () => {
|
|
1170
|
+
mockQuery
|
|
1171
|
+
.mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1 } }] }] })
|
|
1172
|
+
.mockResolvedValueOnce({ rows: [] });
|
|
1173
|
+
const result = await mutationPreview({
|
|
1174
|
+
sql: "UPDATE myschema.users SET name = 'test' WHERE id = 1"
|
|
1175
|
+
});
|
|
1176
|
+
expect(result.targetTable).toBe('myschema.users');
|
|
1177
|
+
});
|
|
1178
|
+
it('should handle quoted table names in DELETE', async () => {
|
|
1179
|
+
mockQuery
|
|
1180
|
+
.mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1 } }] }] })
|
|
1181
|
+
.mockResolvedValueOnce({ rows: [] });
|
|
1182
|
+
const result = await mutationPreview({
|
|
1183
|
+
sql: `DELETE FROM "MyTable" WHERE id = 1`
|
|
1184
|
+
});
|
|
1185
|
+
expect(result.targetTable).toBe('MyTable');
|
|
1186
|
+
});
|
|
1187
|
+
it('should fallback to COUNT when EXPLAIN fails', async () => {
|
|
1188
|
+
mockQuery
|
|
1189
|
+
.mockRejectedValueOnce(new Error('EXPLAIN failed'))
|
|
1190
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
1191
|
+
.mockResolvedValueOnce({ rows: [{ cnt: '42' }] });
|
|
1192
|
+
const result = await mutationPreview({
|
|
1193
|
+
sql: "DELETE FROM users WHERE status = 'old'"
|
|
1194
|
+
});
|
|
1195
|
+
expect(result.estimatedRowsAffected).toBe(42);
|
|
1196
|
+
});
|
|
451
1197
|
});
|
|
452
1198
|
});
|
|
453
1199
|
//# sourceMappingURL=sql-tools.test.js.map
|