@leonardovida-md/drizzle-neo-duckdb 1.1.3 → 1.1.4

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.
@@ -233,6 +233,7 @@ type JoinClause = {
233
233
  /**
234
234
  * Extracts the identifier name from a quoted identifier like "foo" -> foo
235
235
  * Uses the original query string, not the scrubbed one.
236
+ * Handles escaped quotes: "col""name" -> col""name
236
237
  */
237
238
  function extractQuotedIdentifier(
238
239
  original: string,
@@ -243,11 +244,15 @@ function extractQuotedIdentifier(
243
244
  }
244
245
 
245
246
  let pos = start + 1;
246
- while (pos < original.length && original[pos] !== '"') {
247
- // Handle escaped quotes
248
- if (original[pos] === '"' && original[pos + 1] === '"') {
249
- pos += 2;
250
- continue;
247
+ while (pos < original.length) {
248
+ if (original[pos] === '"') {
249
+ // Check for escaped quote ""
250
+ if (original[pos + 1] === '"') {
251
+ pos += 2;
252
+ continue;
253
+ }
254
+ // End of identifier
255
+ break;
251
256
  }
252
257
  pos++;
253
258
  }
@@ -340,7 +345,8 @@ function parseTableSources(original: string, scrubbed: string): TableSource[] {
340
345
 
341
346
  /**
342
347
  * Parses a table reference (potentially with alias) starting at the given position.
343
- * Handles: "table", "table" "alias", "table" as "alias", "schema"."table"
348
+ * Handles: "table", "table" "alias", "table" as "alias", "schema"."table",
349
+ * and subqueries: (SELECT ...) AS "alias"
344
350
  */
345
351
  function parseTableRef(
346
352
  original: string,
@@ -351,6 +357,48 @@ function parseTableRef(
351
357
  while (pos < scrubbed.length && isWhitespace(scrubbed[pos])) {
352
358
  pos++;
353
359
  }
360
+
361
+ // Handle subquery: (SELECT ...) AS "alias"
362
+ if (scrubbed[pos] === '(') {
363
+ const nameStart = pos;
364
+ // Find matching closing parenthesis
365
+ let depth = 1;
366
+ pos++;
367
+ while (pos < scrubbed.length && depth > 0) {
368
+ if (scrubbed[pos] === '(') depth++;
369
+ else if (scrubbed[pos] === ')') depth--;
370
+ pos++;
371
+ }
372
+
373
+ // Skip whitespace after subquery
374
+ while (pos < scrubbed.length && isWhitespace(scrubbed[pos])) {
375
+ pos++;
376
+ }
377
+
378
+ // Look for AS keyword
379
+ const afterSubquery = scrubbed.slice(pos).toLowerCase();
380
+ if (afterSubquery.startsWith('as ')) {
381
+ pos += 3;
382
+ while (pos < scrubbed.length && isWhitespace(scrubbed[pos])) {
383
+ pos++;
384
+ }
385
+ }
386
+
387
+ // Extract alias
388
+ if (original[pos] === '"') {
389
+ const aliasIdent = extractQuotedIdentifier(original, pos);
390
+ if (aliasIdent) {
391
+ return {
392
+ name: aliasIdent.name,
393
+ alias: aliasIdent.name,
394
+ position: nameStart,
395
+ };
396
+ }
397
+ }
398
+
399
+ return null;
400
+ }
401
+
354
402
  if (original[pos] !== '"') {
355
403
  return null;
356
404
  }
@@ -398,20 +446,21 @@ function parseTableRef(
398
446
  }
399
447
  }
400
448
 
449
+ // Check what comes after (potentially skipping the AS keyword)
450
+ const afterAlias = scrubbed.slice(aliasPos).toLowerCase();
401
451
  if (
402
452
  original[aliasPos] === '"' &&
403
- !afterTable.startsWith('on ') &&
404
- !afterTable.startsWith('left ') &&
405
- !afterTable.startsWith('right ') &&
406
- !afterTable.startsWith('inner ') &&
407
- !afterTable.startsWith('full ') &&
408
- !afterTable.startsWith('cross ') &&
409
- !afterTable.startsWith('join ') &&
410
- !afterTable.startsWith('where ') &&
411
- !afterTable.startsWith('group ') &&
412
- !afterTable.startsWith('order ') &&
413
- !afterTable.startsWith('limit ') &&
414
- !afterTable.startsWith('as ')
453
+ !afterAlias.startsWith('on ') &&
454
+ !afterAlias.startsWith('left ') &&
455
+ !afterAlias.startsWith('right ') &&
456
+ !afterAlias.startsWith('inner ') &&
457
+ !afterAlias.startsWith('full ') &&
458
+ !afterAlias.startsWith('cross ') &&
459
+ !afterAlias.startsWith('join ') &&
460
+ !afterAlias.startsWith('where ') &&
461
+ !afterAlias.startsWith('group ') &&
462
+ !afterAlias.startsWith('order ') &&
463
+ !afterAlias.startsWith('limit ')
415
464
  ) {
416
465
  const aliasIdent = extractQuotedIdentifier(original, aliasPos);
417
466
  if (aliasIdent) {
@@ -432,7 +481,8 @@ function parseTableRef(
432
481
  function findJoinClauses(
433
482
  original: string,
434
483
  scrubbed: string,
435
- sources: TableSource[]
484
+ sources: TableSource[],
485
+ fromPos: number
436
486
  ): JoinClause[] {
437
487
  const clauses: JoinClause[] = [];
438
488
  const lowerScrubbed = scrubbed.toLowerCase();
@@ -443,6 +493,9 @@ function findJoinClauses(
443
493
  const joinPattern =
444
494
  /\b(left\s+|right\s+|inner\s+|full\s+|cross\s+)?join\s+"[^"]*"(\s*\.\s*"[^"]*")?(\s+as)?(\s+"[^"]*")?\s+on\s+/gi;
445
495
 
496
+ // Start searching from the main FROM clause, skipping JOINs inside CTE definitions
497
+ joinPattern.lastIndex = fromPos;
498
+
446
499
  let match;
447
500
  let sourceIndex = 1; // Start at 1 since index 0 is the FROM table
448
501
 
@@ -532,12 +585,261 @@ function findJoinClauses(
532
585
  }
533
586
 
534
587
  /**
535
- * Qualifies unqualified column references in JOIN ON clauses.
588
+ * Finds the boundaries of the main query's SELECT clause (after CTEs).
589
+ * Returns the start position (after "select ") and end position (before "from ").
590
+ */
591
+ function findMainSelectClause(
592
+ scrubbed: string
593
+ ): { start: number; end: number } | null {
594
+ const lowerScrubbed = scrubbed.toLowerCase();
595
+
596
+ // If there's a WITH clause, find where the CTEs end
597
+ let searchStart = 0;
598
+ const withMatch = /\bwith\s+/i.exec(lowerScrubbed);
599
+ if (withMatch) {
600
+ // Find the main SELECT after the CTEs
601
+ let depth = 0;
602
+ let pos = withMatch.index + withMatch[0].length;
603
+
604
+ while (pos < scrubbed.length) {
605
+ const char = scrubbed[pos];
606
+ if (char === '(') {
607
+ depth++;
608
+ } else if (char === ')') {
609
+ depth--;
610
+ } else if (depth === 0) {
611
+ const remaining = lowerScrubbed.slice(pos);
612
+ if (/^\s*select\s+/i.test(remaining)) {
613
+ searchStart = pos;
614
+ break;
615
+ }
616
+ }
617
+ pos++;
618
+ }
619
+ }
620
+
621
+ // Find SELECT keyword
622
+ const selectPattern = /\bselect\s+/gi;
623
+ selectPattern.lastIndex = searchStart;
624
+ const selectMatch = selectPattern.exec(lowerScrubbed);
625
+ if (!selectMatch) return null;
626
+
627
+ const selectStart = selectMatch.index + selectMatch[0].length;
628
+
629
+ // Find FROM keyword at same depth
630
+ let depth = 0;
631
+ let pos = selectStart;
632
+ while (pos < scrubbed.length) {
633
+ const char = scrubbed[pos];
634
+ if (char === '(') {
635
+ depth++;
636
+ } else if (char === ')') {
637
+ depth--;
638
+ } else if (depth === 0) {
639
+ const remaining = lowerScrubbed.slice(pos);
640
+ if (/^\s*from\s+/i.test(remaining)) {
641
+ return { start: selectStart, end: pos };
642
+ }
643
+ }
644
+ pos++;
645
+ }
646
+
647
+ return null;
648
+ }
649
+
650
+ /**
651
+ * Finds WHERE clause boundaries in the main query.
652
+ */
653
+ function findWhereClause(
654
+ scrubbed: string,
655
+ fromPos: number
656
+ ): { start: number; end: number } | null {
657
+ const lowerScrubbed = scrubbed.toLowerCase();
658
+
659
+ // Find WHERE keyword after FROM, at depth 0
660
+ let depth = 0;
661
+ let pos = fromPos;
662
+ let whereStart = -1;
663
+
664
+ while (pos < scrubbed.length) {
665
+ const char = scrubbed[pos];
666
+ if (char === '(') {
667
+ depth++;
668
+ } else if (char === ')') {
669
+ depth--;
670
+ } else if (depth === 0) {
671
+ const remaining = lowerScrubbed.slice(pos);
672
+ if (whereStart === -1 && /^\s*where\s+/i.test(remaining)) {
673
+ const match = /^\s*where\s+/i.exec(remaining);
674
+ if (match) {
675
+ whereStart = pos + match[0].length;
676
+ }
677
+ } else if (
678
+ whereStart !== -1 &&
679
+ /^\s*(group\s+by|order\s+by|limit|having|union|intersect|except|$)/i.test(
680
+ remaining
681
+ )
682
+ ) {
683
+ return { start: whereStart, end: pos };
684
+ }
685
+ }
686
+ pos++;
687
+ }
688
+
689
+ if (whereStart !== -1) {
690
+ return { start: whereStart, end: scrubbed.length };
691
+ }
692
+
693
+ return null;
694
+ }
695
+
696
+ /**
697
+ * Finds ORDER BY clause boundaries in the main query.
698
+ */
699
+ function findOrderByClause(
700
+ scrubbed: string,
701
+ fromPos: number
702
+ ): { start: number; end: number } | null {
703
+ const lowerScrubbed = scrubbed.toLowerCase();
704
+
705
+ // Find ORDER BY keyword after FROM, at depth 0
706
+ let depth = 0;
707
+ let pos = fromPos;
708
+ let orderStart = -1;
709
+
710
+ while (pos < scrubbed.length) {
711
+ const char = scrubbed[pos];
712
+ if (char === '(') {
713
+ depth++;
714
+ } else if (char === ')') {
715
+ depth--;
716
+ } else if (depth === 0) {
717
+ const remaining = lowerScrubbed.slice(pos);
718
+ if (orderStart === -1 && /^\s*order\s+by\s+/i.test(remaining)) {
719
+ const match = /^\s*order\s+by\s+/i.exec(remaining);
720
+ if (match) {
721
+ orderStart = pos + match[0].length;
722
+ }
723
+ } else if (
724
+ orderStart !== -1 &&
725
+ /^\s*(limit|offset|fetch|for\s+update|$)/i.test(remaining)
726
+ ) {
727
+ return { start: orderStart, end: pos };
728
+ }
729
+ }
730
+ pos++;
731
+ }
732
+
733
+ if (orderStart !== -1) {
734
+ return { start: orderStart, end: scrubbed.length };
735
+ }
736
+
737
+ return null;
738
+ }
739
+
740
+ /**
741
+ * Qualifies only specific column references (from the ambiguousColumns set) in a clause.
742
+ * This handles SELECT, WHERE, and ORDER BY clauses where columns from joined tables
743
+ * could be ambiguous.
744
+ */
745
+ function qualifyClauseColumnsSelective(
746
+ original: string,
747
+ scrubbed: string,
748
+ clauseStart: number,
749
+ clauseEnd: number,
750
+ defaultSource: string,
751
+ ambiguousColumns: Set<string>
752
+ ): { result: string; offset: number } {
753
+ const clauseOriginal = original.slice(clauseStart, clauseEnd);
754
+ const clauseScrubbed = scrubbed.slice(clauseStart, clauseEnd);
755
+
756
+ let result = clauseOriginal;
757
+ let offset = 0;
758
+
759
+ // Find all unqualified quoted identifiers
760
+ let pos = 0;
761
+ while (pos < clauseScrubbed.length) {
762
+ // Skip to next quote
763
+ const quotePos = clauseScrubbed.indexOf('"', pos);
764
+ if (quotePos === -1) break;
765
+
766
+ // Check if this is already qualified (preceded by a dot)
767
+ if (quotePos > 0 && clauseScrubbed[quotePos - 1] === '.') {
768
+ // Skip this identifier - it's already qualified
769
+ const ident = extractQuotedIdentifier(clauseOriginal, quotePos);
770
+ pos = ident ? ident.end : quotePos + 1;
771
+ continue;
772
+ }
773
+
774
+ // Extract the identifier
775
+ const ident = extractQuotedIdentifier(clauseOriginal, quotePos);
776
+ if (!ident) {
777
+ pos = quotePos + 1;
778
+ continue;
779
+ }
780
+
781
+ // Check if this identifier is followed by a dot (it's a table qualifier, not a column)
782
+ if (
783
+ ident.end < clauseScrubbed.length &&
784
+ clauseScrubbed[ident.end] === '.'
785
+ ) {
786
+ pos = ident.end + 1;
787
+ continue;
788
+ }
789
+
790
+ // Only qualify if this column is in the ambiguous set
791
+ if (!ambiguousColumns.has(ident.name)) {
792
+ pos = ident.end;
793
+ continue;
794
+ }
795
+
796
+ // Check if this looks like a column reference (not a function call or alias definition)
797
+ // Skip if followed by ( which would indicate a function
798
+ let afterIdent = ident.end;
799
+ while (
800
+ afterIdent < clauseScrubbed.length &&
801
+ isWhitespace(clauseScrubbed[afterIdent])
802
+ ) {
803
+ afterIdent++;
804
+ }
805
+ if (clauseScrubbed[afterIdent] === '(') {
806
+ pos = ident.end;
807
+ continue;
808
+ }
809
+
810
+ // Skip if this is an alias definition (preceded by AS or follows a column expression)
811
+ // Look for patterns like: "col" as "alias" or just "col", "alias"
812
+ // We need to be careful here - we only want to qualify column references, not aliases
813
+ const beforeQuote = clauseScrubbed.slice(0, quotePos).toLowerCase();
814
+ if (/\bas\s*$/i.test(beforeQuote)) {
815
+ pos = ident.end;
816
+ continue;
817
+ }
818
+
819
+ // Qualify this column reference
820
+ const qualified = `"${defaultSource}"."${ident.name}"`;
821
+ const oldLength = ident.end - quotePos;
822
+
823
+ result =
824
+ result.slice(0, quotePos + offset) +
825
+ qualified +
826
+ result.slice(quotePos + oldLength + offset);
827
+
828
+ offset += qualified.length - oldLength;
829
+ pos = ident.end;
830
+ }
831
+
832
+ return { result, offset };
833
+ }
834
+
835
+ /**
836
+ * Qualifies unqualified column references in JOIN ON clauses, SELECT, WHERE,
837
+ * and ORDER BY clauses.
536
838
  *
537
839
  * Transforms patterns like:
538
- * `left join "b" on "col" = "col"`
840
+ * `select "col" from "a" left join "b" on "col" = "col" where "col" in (...)`
539
841
  * To:
540
- * `left join "b" on "a"."col" = "b"."col"`
842
+ * `select "a"."col" from "a" left join "b" on "a"."col" = "b"."col" where "a"."col" in (...)`
541
843
  *
542
844
  * This fixes the issue where drizzle-orm generates unqualified column
543
845
  * references when joining CTEs with eq().
@@ -549,20 +851,30 @@ export function qualifyJoinColumns(query: string): string {
549
851
  }
550
852
 
551
853
  const scrubbed = scrubForRewrite(query);
854
+ const fromPos = findMainFromClause(scrubbed);
855
+ if (fromPos < 0) {
856
+ return query;
857
+ }
858
+
552
859
  const sources = parseTableSources(query, scrubbed);
553
860
  if (sources.length < 2) {
554
861
  return query;
555
862
  }
556
863
 
557
- const joinClauses = findJoinClauses(query, scrubbed, sources);
864
+ const joinClauses = findJoinClauses(query, scrubbed, sources, fromPos);
558
865
 
559
866
  if (joinClauses.length === 0) {
560
867
  return query;
561
868
  }
562
869
 
870
+ // Get the first source (FROM table) as the default qualifier
871
+ const firstSource = sources[0]!;
872
+ const defaultQualifier = firstSource.alias || firstSource.name;
873
+
563
874
  let result = query;
564
- let offset = 0;
875
+ let totalOffset = 0;
565
876
 
877
+ // First, qualify ON clauses (existing logic)
566
878
  for (const join of joinClauses) {
567
879
  const scrubbedOnClause = scrubbed.slice(join.onStart, join.onEnd);
568
880
  const originalOnClause = query.slice(join.onStart, join.onEnd);
@@ -576,8 +888,19 @@ export function qualifyJoinColumns(query: string): string {
576
888
  }
577
889
  if (scrubbedOnClause[lhsEnd] !== '"') continue;
578
890
 
891
+ // Find start of identifier, handling escaped quotes ""
579
892
  let lhsStartPos = lhsEnd - 1;
580
- while (lhsStartPos >= 0 && scrubbedOnClause[lhsStartPos] !== '"') {
893
+ while (lhsStartPos >= 0) {
894
+ if (scrubbedOnClause[lhsStartPos] === '"') {
895
+ // Check if this is an escaped quote ""
896
+ if (lhsStartPos > 0 && scrubbedOnClause[lhsStartPos - 1] === '"') {
897
+ // Skip over the escaped quote pair
898
+ lhsStartPos -= 2;
899
+ continue;
900
+ }
901
+ // Found the opening quote
902
+ break;
903
+ }
581
904
  lhsStartPos--;
582
905
  }
583
906
  if (lhsStartPos < 0) continue;
@@ -697,10 +1020,140 @@ export function qualifyJoinColumns(query: string): string {
697
1020
 
698
1021
  if (clauseResult !== originalOnClause) {
699
1022
  result =
700
- result.slice(0, join.onStart + offset) +
1023
+ result.slice(0, join.onStart + totalOffset) +
701
1024
  clauseResult +
702
- result.slice(join.onEnd + offset);
703
- offset += clauseResult.length - originalOnClause.length;
1025
+ result.slice(join.onEnd + totalOffset);
1026
+ totalOffset += clauseResult.length - originalOnClause.length;
1027
+ }
1028
+ }
1029
+
1030
+ // Collect column names that are known to be ambiguous (appeared in ON clauses with same name on both sides)
1031
+ const ambiguousColumns = new Set<string>();
1032
+ for (const join of joinClauses) {
1033
+ const scrubbedOnClause = scrubbed.slice(join.onStart, join.onEnd);
1034
+ const originalOnClause = query.slice(join.onStart, join.onEnd);
1035
+
1036
+ let eqPos = -1;
1037
+ while ((eqPos = scrubbedOnClause.indexOf('=', eqPos + 1)) !== -1) {
1038
+ let lhsEnd = eqPos - 1;
1039
+ while (lhsEnd >= 0 && isWhitespace(scrubbedOnClause[lhsEnd])) {
1040
+ lhsEnd--;
1041
+ }
1042
+ if (scrubbedOnClause[lhsEnd] !== '"') continue;
1043
+
1044
+ let lhsStartPos = lhsEnd - 1;
1045
+ while (lhsStartPos >= 0) {
1046
+ if (scrubbedOnClause[lhsStartPos] === '"') {
1047
+ if (lhsStartPos > 0 && scrubbedOnClause[lhsStartPos - 1] === '"') {
1048
+ lhsStartPos -= 2;
1049
+ continue;
1050
+ }
1051
+ break;
1052
+ }
1053
+ lhsStartPos--;
1054
+ }
1055
+ if (lhsStartPos < 0) continue;
1056
+
1057
+ let rhsStartPos = eqPos + 1;
1058
+ while (
1059
+ rhsStartPos < scrubbedOnClause.length &&
1060
+ isWhitespace(scrubbedOnClause[rhsStartPos])
1061
+ ) {
1062
+ rhsStartPos++;
1063
+ }
1064
+
1065
+ if (originalOnClause[rhsStartPos] !== '"') continue;
1066
+
1067
+ const lhsIdent = extractQuotedIdentifier(originalOnClause, lhsStartPos);
1068
+ const rhsIdent = extractQuotedIdentifier(originalOnClause, rhsStartPos);
1069
+
1070
+ if (lhsIdent && rhsIdent && lhsIdent.name === rhsIdent.name) {
1071
+ ambiguousColumns.add(lhsIdent.name);
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ // If no ambiguous columns were found, we're done
1077
+ if (ambiguousColumns.size === 0) {
1078
+ return result;
1079
+ }
1080
+
1081
+ // Now qualify SELECT, WHERE, and ORDER BY clauses - only for ambiguous columns
1082
+ // We need to re-scrub since the query may have changed
1083
+ const updatedScrubbed = scrubForRewrite(result);
1084
+
1085
+ // Qualify SELECT clause
1086
+ const selectClause = findMainSelectClause(updatedScrubbed);
1087
+ if (selectClause) {
1088
+ const { result: selectResult, offset: selectOffset } =
1089
+ qualifyClauseColumnsSelective(
1090
+ result,
1091
+ updatedScrubbed,
1092
+ selectClause.start,
1093
+ selectClause.end,
1094
+ defaultQualifier,
1095
+ ambiguousColumns
1096
+ );
1097
+ if (selectOffset !== 0) {
1098
+ // Splice the modified clause back into the full query
1099
+ result =
1100
+ result.slice(0, selectClause.start) +
1101
+ selectResult +
1102
+ result.slice(selectClause.end);
1103
+ }
1104
+ }
1105
+
1106
+ // Re-scrub after SELECT changes
1107
+ const scrubbed2 = scrubForRewrite(result);
1108
+ const fromPos2 = findMainFromClause(scrubbed2);
1109
+
1110
+ // Qualify WHERE clause
1111
+ if (fromPos2 >= 0) {
1112
+ const whereClause = findWhereClause(scrubbed2, fromPos2);
1113
+ if (whereClause) {
1114
+ const { result: whereResult, offset: whereOffset } =
1115
+ qualifyClauseColumnsSelective(
1116
+ result,
1117
+ scrubbed2,
1118
+ whereClause.start,
1119
+ whereClause.end,
1120
+ defaultQualifier,
1121
+ ambiguousColumns
1122
+ );
1123
+ if (whereOffset !== 0) {
1124
+ // Splice the modified clause back into the full query
1125
+ result =
1126
+ result.slice(0, whereClause.start) +
1127
+ whereResult +
1128
+ result.slice(whereClause.end);
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ // Re-scrub after WHERE changes
1134
+ const scrubbed3 = scrubForRewrite(result);
1135
+ const fromPos3 = findMainFromClause(scrubbed3);
1136
+
1137
+ // Qualify ORDER BY clause
1138
+ if (fromPos3 >= 0) {
1139
+ const orderByClause = findOrderByClause(scrubbed3, fromPos3);
1140
+ if (orderByClause) {
1141
+ const { result: orderResult, offset: orderOffset } =
1142
+ qualifyClauseColumnsSelective(
1143
+ result,
1144
+ scrubbed3,
1145
+ orderByClause.start,
1146
+ orderByClause.end,
1147
+ defaultQualifier,
1148
+ ambiguousColumns
1149
+ );
1150
+ if (orderOffset !== 0) {
1151
+ // Splice the modified clause back into the full query
1152
+ result =
1153
+ result.slice(0, orderByClause.start) +
1154
+ orderResult +
1155
+ result.slice(orderByClause.end);
1156
+ }
704
1157
  }
705
1158
  }
706
1159