@tejasanik/postgres-mcp-server 2.0.0 → 2.1.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 +227 -2
- package/dist/__tests__/sql-tools.test.js +713 -0
- package/dist/__tests__/sql-tools.test.js.map +1 -1
- package/dist/db-manager.d.ts.map +1 -1
- package/dist/db-manager.js +1 -0
- package/dist/db-manager.js.map +1 -1
- package/dist/index.js +89 -2
- package/dist/index.js.map +1 -1
- package/dist/tools/sql-tools.d.ts +82 -1
- package/dist/tools/sql-tools.d.ts.map +1 -1
- package/dist/tools/sql-tools.js +741 -7
- package/dist/tools/sql-tools.js.map +1 -1
- package/dist/types.d.ts +158 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/utils/masking.d.ts +0 -18
- package/dist/utils/masking.d.ts.map +0 -1
- package/dist/utils/masking.js +0 -68
- package/dist/utils/masking.js.map +0 -1
|
@@ -516,6 +516,719 @@ INVALID;`);
|
|
|
516
516
|
expect(result.errors[0].lineNumber).toBe(8);
|
|
517
517
|
});
|
|
518
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
|
+
});
|
|
519
1232
|
describe('allowMultipleStatements', () => {
|
|
520
1233
|
it('should execute multiple statements and return results for each', async () => {
|
|
521
1234
|
mockQuery
|