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