@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.
@@ -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