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

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.
@@ -1,1161 +0,0 @@
1
- type ArrayOperator = {
2
- token: '@>' | '<@' | '&&';
3
- fn: 'array_has_all' | 'array_has_any';
4
- swap?: boolean;
5
- };
6
-
7
- const OPERATORS: ArrayOperator[] = [
8
- { token: '@>', fn: 'array_has_all' },
9
- { token: '<@', fn: 'array_has_all', swap: true },
10
- { token: '&&', fn: 'array_has_any' },
11
- ];
12
-
13
- const isWhitespace = (char: string | undefined) =>
14
- char !== undefined && /\s/.test(char);
15
-
16
- export function scrubForRewrite(query: string): string {
17
- let scrubbed = '';
18
- type State = 'code' | 'single' | 'double' | 'lineComment' | 'blockComment';
19
- let state: State = 'code';
20
-
21
- for (let i = 0; i < query.length; i += 1) {
22
- const char = query[i]!;
23
- const next = query[i + 1];
24
-
25
- if (state === 'code') {
26
- if (char === "'") {
27
- scrubbed += "'";
28
- state = 'single';
29
- continue;
30
- }
31
- if (char === '"') {
32
- scrubbed += '"';
33
- state = 'double';
34
- continue;
35
- }
36
- if (char === '-' && next === '-') {
37
- scrubbed += ' ';
38
- i += 1;
39
- state = 'lineComment';
40
- continue;
41
- }
42
- if (char === '/' && next === '*') {
43
- scrubbed += ' ';
44
- i += 1;
45
- state = 'blockComment';
46
- continue;
47
- }
48
-
49
- scrubbed += char;
50
- continue;
51
- }
52
-
53
- if (state === 'single') {
54
- if (char === "'" && next === "'") {
55
- scrubbed += "''";
56
- i += 1;
57
- continue;
58
- }
59
- // Preserve quote for boundary detection but mask inner chars with a
60
- // non-whitespace placeholder to avoid false positives on operators.
61
- scrubbed += char === "'" ? "'" : '.';
62
- if (char === "'") {
63
- state = 'code';
64
- }
65
- continue;
66
- }
67
-
68
- if (state === 'double') {
69
- if (char === '"' && next === '"') {
70
- scrubbed += '""';
71
- i += 1;
72
- continue;
73
- }
74
- scrubbed += char === '"' ? '"' : '.';
75
- if (char === '"') {
76
- state = 'code';
77
- }
78
- continue;
79
- }
80
-
81
- if (state === 'lineComment') {
82
- scrubbed += char === '\n' ? '\n' : ' ';
83
- if (char === '\n') {
84
- state = 'code';
85
- }
86
- continue;
87
- }
88
-
89
- if (state === 'blockComment') {
90
- if (char === '*' && next === '/') {
91
- scrubbed += ' ';
92
- i += 1;
93
- state = 'code';
94
- } else {
95
- scrubbed += ' ';
96
- }
97
- }
98
- }
99
-
100
- return scrubbed;
101
- }
102
-
103
- function findNextOperator(
104
- scrubbed: string,
105
- start: number
106
- ): { index: number; operator: ArrayOperator } | null {
107
- for (let idx = start; idx < scrubbed.length; idx += 1) {
108
- for (const operator of OPERATORS) {
109
- if (scrubbed.startsWith(operator.token, idx)) {
110
- return { index: idx, operator };
111
- }
112
- }
113
- }
114
- return null;
115
- }
116
-
117
- function walkLeft(
118
- source: string,
119
- scrubbed: string,
120
- start: number
121
- ): [number, string] {
122
- let idx = start;
123
- while (idx >= 0 && isWhitespace(scrubbed[idx])) {
124
- idx -= 1;
125
- }
126
-
127
- let depth = 0;
128
- for (; idx >= 0; idx -= 1) {
129
- const ch = scrubbed[idx];
130
- if (ch === ')' || ch === ']') {
131
- depth += 1;
132
- } else if (ch === '(' || ch === '[') {
133
- if (depth === 0) {
134
- return [idx + 1, source.slice(idx + 1, start + 1)];
135
- }
136
- depth = Math.max(0, depth - 1);
137
- } else if (depth === 0 && isWhitespace(ch)) {
138
- return [idx + 1, source.slice(idx + 1, start + 1)];
139
- }
140
- }
141
-
142
- return [0, source.slice(0, start + 1)];
143
- }
144
-
145
- function walkRight(
146
- source: string,
147
- scrubbed: string,
148
- start: number
149
- ): [number, string] {
150
- let idx = start;
151
- while (idx < scrubbed.length && isWhitespace(scrubbed[idx])) {
152
- idx += 1;
153
- }
154
-
155
- let depth = 0;
156
- for (; idx < scrubbed.length; idx += 1) {
157
- const ch = scrubbed[idx];
158
- if (ch === '(' || ch === '[') {
159
- depth += 1;
160
- } else if (ch === ')' || ch === ']') {
161
- if (depth === 0) {
162
- return [idx, source.slice(start, idx)];
163
- }
164
- depth = Math.max(0, depth - 1);
165
- } else if (depth === 0 && isWhitespace(ch)) {
166
- return [idx, source.slice(start, idx)];
167
- }
168
- }
169
-
170
- return [scrubbed.length, source.slice(start)];
171
- }
172
-
173
- export function adaptArrayOperators(query: string): string {
174
- if (
175
- query.indexOf('@>') === -1 &&
176
- query.indexOf('<@') === -1 &&
177
- query.indexOf('&&') === -1
178
- ) {
179
- return query;
180
- }
181
-
182
- let rewritten = query;
183
- let scrubbed = scrubForRewrite(query);
184
- let searchStart = 0;
185
-
186
- // Re-run after each replacement to keep indexes aligned with the current string
187
- while (true) {
188
- const next = findNextOperator(scrubbed, searchStart);
189
- if (!next) break;
190
-
191
- const { index, operator } = next;
192
- const [leftStart, leftExpr] = walkLeft(rewritten, scrubbed, index - 1);
193
- const [rightEnd, rightExpr] = walkRight(
194
- rewritten,
195
- scrubbed,
196
- index + operator.token.length
197
- );
198
-
199
- const left = leftExpr.trim();
200
- const right = rightExpr.trim();
201
-
202
- const replacement = `${operator.fn}(${operator.swap ? right : left}, ${
203
- operator.swap ? left : right
204
- })`;
205
-
206
- rewritten =
207
- rewritten.slice(0, leftStart) + replacement + rewritten.slice(rightEnd);
208
- scrubbed = scrubForRewrite(rewritten);
209
- searchStart = leftStart + replacement.length;
210
- }
211
-
212
- return rewritten;
213
- }
214
-
215
- // Join column qualification types and helpers
216
-
217
- type TableSource = {
218
- name: string; // The table/CTE name (without quotes)
219
- alias?: string; // Optional alias
220
- position: number; // Position in the query where this source was introduced
221
- };
222
-
223
- type JoinClause = {
224
- joinType: string; // 'left', 'right', 'inner', 'full', 'cross', ''
225
- tableName: string; // The joined table name
226
- tableAlias?: string; // Optional alias
227
- onStart: number; // Start of ON clause content (after "on ")
228
- onEnd: number; // End of ON clause content
229
- leftSource: string; // The table/alias on the left side of this join
230
- rightSource: string; // The table/alias for the joined table
231
- };
232
-
233
- /**
234
- * Extracts the identifier name from a quoted identifier like "foo" -> foo
235
- * Uses the original query string, not the scrubbed one.
236
- * Handles escaped quotes: "col""name" -> col""name
237
- */
238
- function extractQuotedIdentifier(
239
- original: string,
240
- start: number
241
- ): { name: string; end: number } | null {
242
- if (original[start] !== '"') {
243
- return null;
244
- }
245
-
246
- let pos = start + 1;
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;
256
- }
257
- pos++;
258
- }
259
-
260
- if (pos >= original.length) {
261
- return null;
262
- }
263
-
264
- return {
265
- name: original.slice(start + 1, pos),
266
- end: pos + 1,
267
- };
268
- }
269
-
270
- /**
271
- * Finds the main SELECT's FROM clause, skipping any FROM inside CTEs.
272
- */
273
- function findMainFromClause(scrubbed: string): number {
274
- const lowerScrubbed = scrubbed.toLowerCase();
275
-
276
- // If there's a WITH clause, find where the CTEs end
277
- let searchStart = 0;
278
- const withMatch = /\bwith\s+/i.exec(lowerScrubbed);
279
- if (withMatch) {
280
- // Find the main SELECT after the CTEs
281
- // CTEs are separated by commas and end with the main SELECT
282
- let depth = 0;
283
- let pos = withMatch.index + withMatch[0].length;
284
-
285
- while (pos < scrubbed.length) {
286
- const char = scrubbed[pos];
287
- if (char === '(') {
288
- depth++;
289
- } else if (char === ')') {
290
- depth--;
291
- } else if (depth === 0) {
292
- // Check if we're at "select" keyword (main query)
293
- const remaining = lowerScrubbed.slice(pos);
294
- if (/^\s*select\s+/i.test(remaining)) {
295
- searchStart = pos;
296
- break;
297
- }
298
- }
299
- pos++;
300
- }
301
- }
302
-
303
- // Find FROM after the main SELECT
304
- const fromPattern = /\bfrom\s+/gi;
305
- fromPattern.lastIndex = searchStart;
306
- const fromMatch = fromPattern.exec(lowerScrubbed);
307
-
308
- return fromMatch ? fromMatch.index + fromMatch[0].length : -1;
309
- }
310
-
311
- /**
312
- * Parses table sources from FROM and JOIN clauses.
313
- * Returns an array of table sources in order of appearance.
314
- */
315
- function parseTableSources(original: string, scrubbed: string): TableSource[] {
316
- const sources: TableSource[] = [];
317
- const lowerScrubbed = scrubbed.toLowerCase();
318
-
319
- // Find the main FROM clause (after CTEs if present)
320
- const fromPos = findMainFromClause(scrubbed);
321
- if (fromPos < 0) {
322
- return sources;
323
- }
324
-
325
- const fromTable = parseTableRef(original, scrubbed, fromPos);
326
- if (fromTable) {
327
- sources.push(fromTable);
328
- }
329
-
330
- const joinPattern =
331
- /\b(left\s+|right\s+|inner\s+|full\s+|cross\s+)?join\s+/gi;
332
- joinPattern.lastIndex = fromPos;
333
-
334
- let joinMatch;
335
- while ((joinMatch = joinPattern.exec(lowerScrubbed)) !== null) {
336
- const tableStart = joinMatch.index + joinMatch[0].length;
337
- const joinTable = parseTableRef(original, scrubbed, tableStart);
338
- if (joinTable) {
339
- sources.push(joinTable);
340
- }
341
- }
342
-
343
- return sources;
344
- }
345
-
346
- /**
347
- * Parses a table reference (potentially with alias) starting at the given position.
348
- * Handles: "table", "table" "alias", "table" as "alias", "schema"."table",
349
- * and subqueries: (SELECT ...) AS "alias"
350
- */
351
- function parseTableRef(
352
- original: string,
353
- scrubbed: string,
354
- start: number
355
- ): TableSource | null {
356
- let pos = start;
357
- while (pos < scrubbed.length && isWhitespace(scrubbed[pos])) {
358
- pos++;
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
-
402
- if (original[pos] !== '"') {
403
- return null;
404
- }
405
-
406
- const nameStart = pos;
407
- const firstIdent = extractQuotedIdentifier(original, pos);
408
- if (!firstIdent) {
409
- return null;
410
- }
411
-
412
- let name = firstIdent.name;
413
- pos = firstIdent.end;
414
-
415
- // Check for schema.table pattern
416
- let afterName = pos;
417
- while (afterName < scrubbed.length && isWhitespace(scrubbed[afterName])) {
418
- afterName++;
419
- }
420
-
421
- if (scrubbed[afterName] === '.') {
422
- afterName++;
423
- while (afterName < scrubbed.length && isWhitespace(scrubbed[afterName])) {
424
- afterName++;
425
- }
426
- if (original[afterName] === '"') {
427
- const tableIdent = extractQuotedIdentifier(original, afterName);
428
- if (tableIdent) {
429
- name = tableIdent.name;
430
- pos = tableIdent.end;
431
- }
432
- }
433
- }
434
-
435
- let alias: string | undefined;
436
- let aliasPos = pos;
437
- while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
438
- aliasPos++;
439
- }
440
-
441
- const afterTable = scrubbed.slice(aliasPos).toLowerCase();
442
- if (afterTable.startsWith('as ')) {
443
- aliasPos += 3;
444
- while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
445
- aliasPos++;
446
- }
447
- }
448
-
449
- // Check what comes after (potentially skipping the AS keyword)
450
- const afterAlias = scrubbed.slice(aliasPos).toLowerCase();
451
- if (
452
- original[aliasPos] === '"' &&
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 ')
464
- ) {
465
- const aliasIdent = extractQuotedIdentifier(original, aliasPos);
466
- if (aliasIdent) {
467
- alias = aliasIdent.name;
468
- }
469
- }
470
-
471
- return {
472
- name,
473
- alias,
474
- position: nameStart,
475
- };
476
- }
477
-
478
- /**
479
- * Finds all JOIN clauses with their ON clause boundaries.
480
- */
481
- function findJoinClauses(
482
- original: string,
483
- scrubbed: string,
484
- sources: TableSource[],
485
- fromPos: number
486
- ): JoinClause[] {
487
- const clauses: JoinClause[] = [];
488
- const lowerScrubbed = scrubbed.toLowerCase();
489
-
490
- // Pattern to find JOINs with ON clauses
491
- // Use scrubbed for matching, original for extracting values
492
- // Handle optional schema prefix: "schema"."table" or just "table"
493
- const joinPattern =
494
- /\b(left\s+|right\s+|inner\s+|full\s+|cross\s+)?join\s+"[^"]*"(\s*\.\s*"[^"]*")?(\s+as)?(\s+"[^"]*")?\s+on\s+/gi;
495
-
496
- // Start searching from the main FROM clause, skipping JOINs inside CTE definitions
497
- joinPattern.lastIndex = fromPos;
498
-
499
- let match;
500
- let sourceIndex = 1; // Start at 1 since index 0 is the FROM table
501
-
502
- while ((match = joinPattern.exec(lowerScrubbed)) !== null) {
503
- const joinType = (match[1] || '').trim().toLowerCase();
504
- const joinKeywordEnd =
505
- match.index + (match[1] || '').length + 'join'.length;
506
- let tableStart = joinKeywordEnd;
507
- while (tableStart < original.length && isWhitespace(original[tableStart])) {
508
- tableStart++;
509
- }
510
-
511
- const tableIdent = extractQuotedIdentifier(original, tableStart);
512
- if (!tableIdent) continue;
513
-
514
- let tableName = tableIdent.name;
515
- let afterTable = tableIdent.end;
516
- let checkPos = afterTable;
517
- while (checkPos < scrubbed.length && isWhitespace(scrubbed[checkPos])) {
518
- checkPos++;
519
- }
520
- if (scrubbed[checkPos] === '.') {
521
- checkPos++;
522
- while (checkPos < scrubbed.length && isWhitespace(scrubbed[checkPos])) {
523
- checkPos++;
524
- }
525
- const realTableIdent = extractQuotedIdentifier(original, checkPos);
526
- if (realTableIdent) {
527
- tableName = realTableIdent.name;
528
- afterTable = realTableIdent.end;
529
- }
530
- }
531
-
532
- let tableAlias: string | undefined;
533
- let aliasPos = afterTable;
534
- while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
535
- aliasPos++;
536
- }
537
-
538
- const afterTableStr = scrubbed.slice(aliasPos).toLowerCase();
539
- if (afterTableStr.startsWith('as ')) {
540
- aliasPos += 3;
541
- while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
542
- aliasPos++;
543
- }
544
- }
545
-
546
- if (original[aliasPos] === '"' && !afterTableStr.startsWith('on ')) {
547
- const aliasIdent = extractQuotedIdentifier(original, aliasPos);
548
- if (aliasIdent) {
549
- tableAlias = aliasIdent.name;
550
- }
551
- }
552
-
553
- const onStart = match.index + match[0].length;
554
-
555
- // Find the end of the ON clause (next JOIN, WHERE, GROUP, ORDER, LIMIT, or end)
556
- const endPattern =
557
- /\b(left\s+join|right\s+join|inner\s+join|full\s+join|cross\s+join|join|where|group\s+by|order\s+by|limit|$)/i;
558
- const remaining = lowerScrubbed.slice(onStart);
559
- const endMatch = endPattern.exec(remaining);
560
- const onEnd = endMatch ? onStart + endMatch.index : scrubbed.length;
561
-
562
- // Determine the left source (previous table/CTE)
563
- let leftSource = '';
564
- if (sourceIndex > 0 && sourceIndex <= sources.length) {
565
- const prev = sources[sourceIndex - 1];
566
- leftSource = prev?.alias || prev?.name || '';
567
- }
568
-
569
- const rightSource = tableAlias || tableName;
570
-
571
- clauses.push({
572
- joinType,
573
- tableName,
574
- tableAlias,
575
- onStart,
576
- onEnd,
577
- leftSource,
578
- rightSource,
579
- });
580
-
581
- sourceIndex++;
582
- }
583
-
584
- return clauses;
585
- }
586
-
587
- /**
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.
838
- *
839
- * Transforms patterns like:
840
- * `select "col" from "a" left join "b" on "col" = "col" where "col" in (...)`
841
- * To:
842
- * `select "a"."col" from "a" left join "b" on "a"."col" = "b"."col" where "a"."col" in (...)`
843
- *
844
- * This fixes the issue where drizzle-orm generates unqualified column
845
- * references when joining CTEs with eq().
846
- */
847
- export function qualifyJoinColumns(query: string): string {
848
- const lowerQuery = query.toLowerCase();
849
- if (!lowerQuery.includes('join')) {
850
- return query;
851
- }
852
-
853
- const scrubbed = scrubForRewrite(query);
854
- const fromPos = findMainFromClause(scrubbed);
855
- if (fromPos < 0) {
856
- return query;
857
- }
858
-
859
- const sources = parseTableSources(query, scrubbed);
860
- if (sources.length < 2) {
861
- return query;
862
- }
863
-
864
- const joinClauses = findJoinClauses(query, scrubbed, sources, fromPos);
865
-
866
- if (joinClauses.length === 0) {
867
- return query;
868
- }
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
-
874
- let result = query;
875
- let totalOffset = 0;
876
-
877
- // First, qualify ON clauses (existing logic)
878
- for (const join of joinClauses) {
879
- const scrubbedOnClause = scrubbed.slice(join.onStart, join.onEnd);
880
- const originalOnClause = query.slice(join.onStart, join.onEnd);
881
- let clauseResult = originalOnClause;
882
- let clauseOffset = 0;
883
- let eqPos = -1;
884
- while ((eqPos = scrubbedOnClause.indexOf('=', eqPos + 1)) !== -1) {
885
- let lhsEnd = eqPos - 1;
886
- while (lhsEnd >= 0 && isWhitespace(scrubbedOnClause[lhsEnd])) {
887
- lhsEnd--;
888
- }
889
- if (scrubbedOnClause[lhsEnd] !== '"') continue;
890
-
891
- // Find start of identifier, handling escaped quotes ""
892
- let lhsStartPos = lhsEnd - 1;
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
- }
904
- lhsStartPos--;
905
- }
906
- if (lhsStartPos < 0) continue;
907
-
908
- const lhsIsQualified =
909
- lhsStartPos > 0 && scrubbedOnClause[lhsStartPos - 1] === '.';
910
- let rhsStartPos = eqPos + 1;
911
- while (
912
- rhsStartPos < scrubbedOnClause.length &&
913
- isWhitespace(scrubbedOnClause[rhsStartPos])
914
- ) {
915
- rhsStartPos++;
916
- }
917
-
918
- const rhsChar = originalOnClause[rhsStartPos];
919
- const rhsIsParam = rhsChar === '$';
920
- const rhsIsStringLiteral = rhsChar === "'";
921
- const rhsIsColumn = rhsChar === '"';
922
-
923
- if (!rhsIsParam && !rhsIsStringLiteral && !rhsIsColumn) continue;
924
-
925
- const rhsIsQualified =
926
- !rhsIsColumn ||
927
- (rhsStartPos > 0 && scrubbedOnClause[rhsStartPos - 1] === '.');
928
- if (lhsIsQualified || rhsIsQualified) continue;
929
-
930
- const lhsIdent = extractQuotedIdentifier(originalOnClause, lhsStartPos);
931
- if (!lhsIdent) continue;
932
-
933
- let rhsIdent: { name: string; end: number } | null = null;
934
- let rhsValue = '';
935
- let rhsEnd = rhsStartPos;
936
-
937
- if (rhsIsParam) {
938
- let paramEnd = rhsStartPos + 1;
939
- while (
940
- paramEnd < originalOnClause.length &&
941
- /\d/.test(originalOnClause[paramEnd]!)
942
- ) {
943
- paramEnd++;
944
- }
945
- rhsValue = originalOnClause.slice(rhsStartPos, paramEnd);
946
- rhsEnd = paramEnd;
947
- } else if (rhsIsStringLiteral) {
948
- let literalEnd = rhsStartPos + 1;
949
- while (literalEnd < originalOnClause.length) {
950
- if (originalOnClause[literalEnd] === "'") {
951
- if (originalOnClause[literalEnd + 1] === "'") {
952
- literalEnd += 2;
953
- continue;
954
- }
955
- break;
956
- }
957
- literalEnd++;
958
- }
959
- rhsValue = originalOnClause.slice(rhsStartPos, literalEnd + 1);
960
- rhsEnd = literalEnd + 1;
961
- } else if (rhsIsColumn) {
962
- rhsIdent = extractQuotedIdentifier(originalOnClause, rhsStartPos);
963
- if (rhsIdent) {
964
- // Check if this identifier is followed by a dot (meaning it's a table prefix, not the column)
965
- if (
966
- rhsIdent.end < scrubbedOnClause.length &&
967
- scrubbedOnClause[rhsIdent.end] === '.'
968
- ) {
969
- // This is a qualified reference "table"."column" - skip, it's already qualified
970
- continue;
971
- }
972
- rhsValue = `"${rhsIdent.name}"`;
973
- rhsEnd = rhsIdent.end;
974
- }
975
- }
976
-
977
- if (!rhsValue) continue;
978
-
979
- // Only qualify when both sides are columns with the same name.
980
- // Only same-named columns cause "Ambiguous reference" errors in DuckDB.
981
- if (!rhsIsColumn || !rhsIdent || lhsIdent.name !== rhsIdent.name) {
982
- continue;
983
- }
984
-
985
- const lhsOriginal = `"${lhsIdent.name}"`;
986
- let newLhs = lhsOriginal;
987
- let newRhs = rhsValue;
988
-
989
- if (!lhsIsQualified && join.leftSource) {
990
- newLhs = `"${join.leftSource}"."${lhsIdent.name}"`;
991
- }
992
-
993
- if (!rhsIsQualified && rhsIsColumn && rhsIdent && join.rightSource) {
994
- newRhs = `"${join.rightSource}"."${rhsIdent.name}"`;
995
- }
996
-
997
- if (newLhs !== lhsOriginal || newRhs !== rhsValue) {
998
- const opStart = lhsIdent.end;
999
- let opEnd = opStart;
1000
- while (
1001
- opEnd < rhsEnd &&
1002
- (isWhitespace(originalOnClause[opEnd]) ||
1003
- originalOnClause[opEnd] === '=')
1004
- ) {
1005
- opEnd++;
1006
- }
1007
- const operator = originalOnClause.slice(opStart, opEnd);
1008
-
1009
- const newExpr = `${newLhs}${operator}${newRhs}`;
1010
- const oldExprLength = rhsEnd - lhsStartPos;
1011
-
1012
- clauseResult =
1013
- clauseResult.slice(0, lhsStartPos + clauseOffset) +
1014
- newExpr +
1015
- clauseResult.slice(lhsStartPos + oldExprLength + clauseOffset);
1016
-
1017
- clauseOffset += newExpr.length - oldExprLength;
1018
- }
1019
- }
1020
-
1021
- if (clauseResult !== originalOnClause) {
1022
- result =
1023
- result.slice(0, join.onStart + totalOffset) +
1024
- clauseResult +
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
- }
1157
- }
1158
- }
1159
-
1160
- return result;
1161
- }