@leonardovida-md/drizzle-neo-duckdb 1.1.2 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/duckdb-introspect.mjs +335 -2
- package/dist/index.mjs +335 -2
- package/dist/sql/query-rewriters.d.ts +12 -0
- package/package.json +1 -1
- package/src/session.ts +22 -3
- package/src/sql/query-rewriters.ts +495 -0
|
@@ -183,6 +183,328 @@ function adaptArrayOperators(query) {
|
|
|
183
183
|
}
|
|
184
184
|
return rewritten;
|
|
185
185
|
}
|
|
186
|
+
function extractQuotedIdentifier(original, start) {
|
|
187
|
+
if (original[start] !== '"') {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
let pos = start + 1;
|
|
191
|
+
while (pos < original.length && original[pos] !== '"') {
|
|
192
|
+
if (original[pos] === '"' && original[pos + 1] === '"') {
|
|
193
|
+
pos += 2;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
pos++;
|
|
197
|
+
}
|
|
198
|
+
if (pos >= original.length) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
name: original.slice(start + 1, pos),
|
|
203
|
+
end: pos + 1
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function findMainFromClause(scrubbed) {
|
|
207
|
+
const lowerScrubbed = scrubbed.toLowerCase();
|
|
208
|
+
let searchStart = 0;
|
|
209
|
+
const withMatch = /\bwith\s+/i.exec(lowerScrubbed);
|
|
210
|
+
if (withMatch) {
|
|
211
|
+
let depth = 0;
|
|
212
|
+
let pos = withMatch.index + withMatch[0].length;
|
|
213
|
+
while (pos < scrubbed.length) {
|
|
214
|
+
const char = scrubbed[pos];
|
|
215
|
+
if (char === "(") {
|
|
216
|
+
depth++;
|
|
217
|
+
} else if (char === ")") {
|
|
218
|
+
depth--;
|
|
219
|
+
} else if (depth === 0) {
|
|
220
|
+
const remaining = lowerScrubbed.slice(pos);
|
|
221
|
+
if (/^\s*select\s+/i.test(remaining)) {
|
|
222
|
+
searchStart = pos;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
pos++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const fromPattern = /\bfrom\s+/gi;
|
|
230
|
+
fromPattern.lastIndex = searchStart;
|
|
231
|
+
const fromMatch = fromPattern.exec(lowerScrubbed);
|
|
232
|
+
return fromMatch ? fromMatch.index + fromMatch[0].length : -1;
|
|
233
|
+
}
|
|
234
|
+
function parseTableSources(original, scrubbed) {
|
|
235
|
+
const sources = [];
|
|
236
|
+
const lowerScrubbed = scrubbed.toLowerCase();
|
|
237
|
+
const fromPos = findMainFromClause(scrubbed);
|
|
238
|
+
if (fromPos < 0) {
|
|
239
|
+
return sources;
|
|
240
|
+
}
|
|
241
|
+
const fromTable = parseTableRef(original, scrubbed, fromPos);
|
|
242
|
+
if (fromTable) {
|
|
243
|
+
sources.push(fromTable);
|
|
244
|
+
}
|
|
245
|
+
const joinPattern = /\b(left\s+|right\s+|inner\s+|full\s+|cross\s+)?join\s+/gi;
|
|
246
|
+
joinPattern.lastIndex = fromPos;
|
|
247
|
+
let joinMatch;
|
|
248
|
+
while ((joinMatch = joinPattern.exec(lowerScrubbed)) !== null) {
|
|
249
|
+
const tableStart = joinMatch.index + joinMatch[0].length;
|
|
250
|
+
const joinTable = parseTableRef(original, scrubbed, tableStart);
|
|
251
|
+
if (joinTable) {
|
|
252
|
+
sources.push(joinTable);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return sources;
|
|
256
|
+
}
|
|
257
|
+
function parseTableRef(original, scrubbed, start) {
|
|
258
|
+
let pos = start;
|
|
259
|
+
while (pos < scrubbed.length && isWhitespace(scrubbed[pos])) {
|
|
260
|
+
pos++;
|
|
261
|
+
}
|
|
262
|
+
if (original[pos] !== '"') {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const nameStart = pos;
|
|
266
|
+
const firstIdent = extractQuotedIdentifier(original, pos);
|
|
267
|
+
if (!firstIdent) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
let name = firstIdent.name;
|
|
271
|
+
pos = firstIdent.end;
|
|
272
|
+
let afterName = pos;
|
|
273
|
+
while (afterName < scrubbed.length && isWhitespace(scrubbed[afterName])) {
|
|
274
|
+
afterName++;
|
|
275
|
+
}
|
|
276
|
+
if (scrubbed[afterName] === ".") {
|
|
277
|
+
afterName++;
|
|
278
|
+
while (afterName < scrubbed.length && isWhitespace(scrubbed[afterName])) {
|
|
279
|
+
afterName++;
|
|
280
|
+
}
|
|
281
|
+
if (original[afterName] === '"') {
|
|
282
|
+
const tableIdent = extractQuotedIdentifier(original, afterName);
|
|
283
|
+
if (tableIdent) {
|
|
284
|
+
name = tableIdent.name;
|
|
285
|
+
pos = tableIdent.end;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
let alias;
|
|
290
|
+
let aliasPos = pos;
|
|
291
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
292
|
+
aliasPos++;
|
|
293
|
+
}
|
|
294
|
+
const afterTable = scrubbed.slice(aliasPos).toLowerCase();
|
|
295
|
+
if (afterTable.startsWith("as ")) {
|
|
296
|
+
aliasPos += 3;
|
|
297
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
298
|
+
aliasPos++;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (original[aliasPos] === '"' && !afterTable.startsWith("on ") && !afterTable.startsWith("left ") && !afterTable.startsWith("right ") && !afterTable.startsWith("inner ") && !afterTable.startsWith("full ") && !afterTable.startsWith("cross ") && !afterTable.startsWith("join ") && !afterTable.startsWith("where ") && !afterTable.startsWith("group ") && !afterTable.startsWith("order ") && !afterTable.startsWith("limit ") && !afterTable.startsWith("as ")) {
|
|
302
|
+
const aliasIdent = extractQuotedIdentifier(original, aliasPos);
|
|
303
|
+
if (aliasIdent) {
|
|
304
|
+
alias = aliasIdent.name;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
name,
|
|
309
|
+
alias,
|
|
310
|
+
position: nameStart
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function findJoinClauses(original, scrubbed, sources) {
|
|
314
|
+
const clauses = [];
|
|
315
|
+
const lowerScrubbed = scrubbed.toLowerCase();
|
|
316
|
+
const joinPattern = /\b(left\s+|right\s+|inner\s+|full\s+|cross\s+)?join\s+"[^"]*"(\s*\.\s*"[^"]*")?(\s+as)?(\s+"[^"]*")?\s+on\s+/gi;
|
|
317
|
+
let match;
|
|
318
|
+
let sourceIndex = 1;
|
|
319
|
+
while ((match = joinPattern.exec(lowerScrubbed)) !== null) {
|
|
320
|
+
const joinType = (match[1] || "").trim().toLowerCase();
|
|
321
|
+
const joinKeywordEnd = match.index + (match[1] || "").length + "join".length;
|
|
322
|
+
let tableStart = joinKeywordEnd;
|
|
323
|
+
while (tableStart < original.length && isWhitespace(original[tableStart])) {
|
|
324
|
+
tableStart++;
|
|
325
|
+
}
|
|
326
|
+
const tableIdent = extractQuotedIdentifier(original, tableStart);
|
|
327
|
+
if (!tableIdent)
|
|
328
|
+
continue;
|
|
329
|
+
let tableName = tableIdent.name;
|
|
330
|
+
let afterTable = tableIdent.end;
|
|
331
|
+
let checkPos = afterTable;
|
|
332
|
+
while (checkPos < scrubbed.length && isWhitespace(scrubbed[checkPos])) {
|
|
333
|
+
checkPos++;
|
|
334
|
+
}
|
|
335
|
+
if (scrubbed[checkPos] === ".") {
|
|
336
|
+
checkPos++;
|
|
337
|
+
while (checkPos < scrubbed.length && isWhitespace(scrubbed[checkPos])) {
|
|
338
|
+
checkPos++;
|
|
339
|
+
}
|
|
340
|
+
const realTableIdent = extractQuotedIdentifier(original, checkPos);
|
|
341
|
+
if (realTableIdent) {
|
|
342
|
+
tableName = realTableIdent.name;
|
|
343
|
+
afterTable = realTableIdent.end;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
let tableAlias;
|
|
347
|
+
let aliasPos = afterTable;
|
|
348
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
349
|
+
aliasPos++;
|
|
350
|
+
}
|
|
351
|
+
const afterTableStr = scrubbed.slice(aliasPos).toLowerCase();
|
|
352
|
+
if (afterTableStr.startsWith("as ")) {
|
|
353
|
+
aliasPos += 3;
|
|
354
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
355
|
+
aliasPos++;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (original[aliasPos] === '"' && !afterTableStr.startsWith("on ")) {
|
|
359
|
+
const aliasIdent = extractQuotedIdentifier(original, aliasPos);
|
|
360
|
+
if (aliasIdent) {
|
|
361
|
+
tableAlias = aliasIdent.name;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const onStart = match.index + match[0].length;
|
|
365
|
+
const endPattern = /\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;
|
|
366
|
+
const remaining = lowerScrubbed.slice(onStart);
|
|
367
|
+
const endMatch = endPattern.exec(remaining);
|
|
368
|
+
const onEnd = endMatch ? onStart + endMatch.index : scrubbed.length;
|
|
369
|
+
let leftSource = "";
|
|
370
|
+
if (sourceIndex > 0 && sourceIndex <= sources.length) {
|
|
371
|
+
const prev = sources[sourceIndex - 1];
|
|
372
|
+
leftSource = prev?.alias || prev?.name || "";
|
|
373
|
+
}
|
|
374
|
+
const rightSource = tableAlias || tableName;
|
|
375
|
+
clauses.push({
|
|
376
|
+
joinType,
|
|
377
|
+
tableName,
|
|
378
|
+
tableAlias,
|
|
379
|
+
onStart,
|
|
380
|
+
onEnd,
|
|
381
|
+
leftSource,
|
|
382
|
+
rightSource
|
|
383
|
+
});
|
|
384
|
+
sourceIndex++;
|
|
385
|
+
}
|
|
386
|
+
return clauses;
|
|
387
|
+
}
|
|
388
|
+
function qualifyJoinColumns(query) {
|
|
389
|
+
const lowerQuery = query.toLowerCase();
|
|
390
|
+
if (!lowerQuery.includes("join")) {
|
|
391
|
+
return query;
|
|
392
|
+
}
|
|
393
|
+
const scrubbed = scrubForRewrite(query);
|
|
394
|
+
const sources = parseTableSources(query, scrubbed);
|
|
395
|
+
if (sources.length < 2) {
|
|
396
|
+
return query;
|
|
397
|
+
}
|
|
398
|
+
const joinClauses = findJoinClauses(query, scrubbed, sources);
|
|
399
|
+
if (joinClauses.length === 0) {
|
|
400
|
+
return query;
|
|
401
|
+
}
|
|
402
|
+
let result = query;
|
|
403
|
+
let offset = 0;
|
|
404
|
+
for (const join of joinClauses) {
|
|
405
|
+
const scrubbedOnClause = scrubbed.slice(join.onStart, join.onEnd);
|
|
406
|
+
const originalOnClause = query.slice(join.onStart, join.onEnd);
|
|
407
|
+
let clauseResult = originalOnClause;
|
|
408
|
+
let clauseOffset = 0;
|
|
409
|
+
let eqPos = -1;
|
|
410
|
+
while ((eqPos = scrubbedOnClause.indexOf("=", eqPos + 1)) !== -1) {
|
|
411
|
+
let lhsEnd = eqPos - 1;
|
|
412
|
+
while (lhsEnd >= 0 && isWhitespace(scrubbedOnClause[lhsEnd])) {
|
|
413
|
+
lhsEnd--;
|
|
414
|
+
}
|
|
415
|
+
if (scrubbedOnClause[lhsEnd] !== '"')
|
|
416
|
+
continue;
|
|
417
|
+
let lhsStartPos = lhsEnd - 1;
|
|
418
|
+
while (lhsStartPos >= 0 && scrubbedOnClause[lhsStartPos] !== '"') {
|
|
419
|
+
lhsStartPos--;
|
|
420
|
+
}
|
|
421
|
+
if (lhsStartPos < 0)
|
|
422
|
+
continue;
|
|
423
|
+
const lhsIsQualified = lhsStartPos > 0 && scrubbedOnClause[lhsStartPos - 1] === ".";
|
|
424
|
+
let rhsStartPos = eqPos + 1;
|
|
425
|
+
while (rhsStartPos < scrubbedOnClause.length && isWhitespace(scrubbedOnClause[rhsStartPos])) {
|
|
426
|
+
rhsStartPos++;
|
|
427
|
+
}
|
|
428
|
+
const rhsChar = originalOnClause[rhsStartPos];
|
|
429
|
+
const rhsIsParam = rhsChar === "$";
|
|
430
|
+
const rhsIsStringLiteral = rhsChar === "'";
|
|
431
|
+
const rhsIsColumn = rhsChar === '"';
|
|
432
|
+
if (!rhsIsParam && !rhsIsStringLiteral && !rhsIsColumn)
|
|
433
|
+
continue;
|
|
434
|
+
const rhsIsQualified = !rhsIsColumn || rhsStartPos > 0 && scrubbedOnClause[rhsStartPos - 1] === ".";
|
|
435
|
+
if (lhsIsQualified || rhsIsQualified)
|
|
436
|
+
continue;
|
|
437
|
+
const lhsIdent = extractQuotedIdentifier(originalOnClause, lhsStartPos);
|
|
438
|
+
if (!lhsIdent)
|
|
439
|
+
continue;
|
|
440
|
+
let rhsIdent = null;
|
|
441
|
+
let rhsValue = "";
|
|
442
|
+
let rhsEnd = rhsStartPos;
|
|
443
|
+
if (rhsIsParam) {
|
|
444
|
+
let paramEnd = rhsStartPos + 1;
|
|
445
|
+
while (paramEnd < originalOnClause.length && /\d/.test(originalOnClause[paramEnd])) {
|
|
446
|
+
paramEnd++;
|
|
447
|
+
}
|
|
448
|
+
rhsValue = originalOnClause.slice(rhsStartPos, paramEnd);
|
|
449
|
+
rhsEnd = paramEnd;
|
|
450
|
+
} else if (rhsIsStringLiteral) {
|
|
451
|
+
let literalEnd = rhsStartPos + 1;
|
|
452
|
+
while (literalEnd < originalOnClause.length) {
|
|
453
|
+
if (originalOnClause[literalEnd] === "'") {
|
|
454
|
+
if (originalOnClause[literalEnd + 1] === "'") {
|
|
455
|
+
literalEnd += 2;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
literalEnd++;
|
|
461
|
+
}
|
|
462
|
+
rhsValue = originalOnClause.slice(rhsStartPos, literalEnd + 1);
|
|
463
|
+
rhsEnd = literalEnd + 1;
|
|
464
|
+
} else if (rhsIsColumn) {
|
|
465
|
+
rhsIdent = extractQuotedIdentifier(originalOnClause, rhsStartPos);
|
|
466
|
+
if (rhsIdent) {
|
|
467
|
+
if (rhsIdent.end < scrubbedOnClause.length && scrubbedOnClause[rhsIdent.end] === ".") {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
rhsValue = `"${rhsIdent.name}"`;
|
|
471
|
+
rhsEnd = rhsIdent.end;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (!rhsValue)
|
|
475
|
+
continue;
|
|
476
|
+
if (!rhsIsColumn || !rhsIdent || lhsIdent.name !== rhsIdent.name) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const lhsOriginal = `"${lhsIdent.name}"`;
|
|
480
|
+
let newLhs = lhsOriginal;
|
|
481
|
+
let newRhs = rhsValue;
|
|
482
|
+
if (!lhsIsQualified && join.leftSource) {
|
|
483
|
+
newLhs = `"${join.leftSource}"."${lhsIdent.name}"`;
|
|
484
|
+
}
|
|
485
|
+
if (!rhsIsQualified && rhsIsColumn && rhsIdent && join.rightSource) {
|
|
486
|
+
newRhs = `"${join.rightSource}"."${rhsIdent.name}"`;
|
|
487
|
+
}
|
|
488
|
+
if (newLhs !== lhsOriginal || newRhs !== rhsValue) {
|
|
489
|
+
const opStart = lhsIdent.end;
|
|
490
|
+
let opEnd = opStart;
|
|
491
|
+
while (opEnd < rhsEnd && (isWhitespace(originalOnClause[opEnd]) || originalOnClause[opEnd] === "=")) {
|
|
492
|
+
opEnd++;
|
|
493
|
+
}
|
|
494
|
+
const operator = originalOnClause.slice(opStart, opEnd);
|
|
495
|
+
const newExpr = `${newLhs}${operator}${newRhs}`;
|
|
496
|
+
const oldExprLength = rhsEnd - lhsStartPos;
|
|
497
|
+
clauseResult = clauseResult.slice(0, lhsStartPos + clauseOffset) + newExpr + clauseResult.slice(lhsStartPos + oldExprLength + clauseOffset);
|
|
498
|
+
clauseOffset += newExpr.length - oldExprLength;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (clauseResult !== originalOnClause) {
|
|
502
|
+
result = result.slice(0, join.onStart + offset) + clauseResult + result.slice(join.onEnd + offset);
|
|
503
|
+
offset += clauseResult.length - originalOnClause.length;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return result;
|
|
507
|
+
}
|
|
186
508
|
|
|
187
509
|
// src/sql/result-mapper.ts
|
|
188
510
|
import {
|
|
@@ -764,8 +1086,19 @@ function rewriteQuery(mode, query) {
|
|
|
764
1086
|
if (mode === "never") {
|
|
765
1087
|
return { sql: query, rewritten: false };
|
|
766
1088
|
}
|
|
767
|
-
|
|
768
|
-
|
|
1089
|
+
let result = query;
|
|
1090
|
+
let wasRewritten = false;
|
|
1091
|
+
const arrayRewritten = adaptArrayOperators(result);
|
|
1092
|
+
if (arrayRewritten !== result) {
|
|
1093
|
+
result = arrayRewritten;
|
|
1094
|
+
wasRewritten = true;
|
|
1095
|
+
}
|
|
1096
|
+
const joinQualified = qualifyJoinColumns(result);
|
|
1097
|
+
if (joinQualified !== result) {
|
|
1098
|
+
result = joinQualified;
|
|
1099
|
+
wasRewritten = true;
|
|
1100
|
+
}
|
|
1101
|
+
return { sql: result, rewritten: wasRewritten };
|
|
769
1102
|
}
|
|
770
1103
|
|
|
771
1104
|
class DuckDBPreparedQuery extends PgPreparedQuery {
|
package/dist/index.mjs
CHANGED
|
@@ -175,6 +175,328 @@ function adaptArrayOperators(query) {
|
|
|
175
175
|
}
|
|
176
176
|
return rewritten;
|
|
177
177
|
}
|
|
178
|
+
function extractQuotedIdentifier(original, start) {
|
|
179
|
+
if (original[start] !== '"') {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
let pos = start + 1;
|
|
183
|
+
while (pos < original.length && original[pos] !== '"') {
|
|
184
|
+
if (original[pos] === '"' && original[pos + 1] === '"') {
|
|
185
|
+
pos += 2;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
pos++;
|
|
189
|
+
}
|
|
190
|
+
if (pos >= original.length) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
name: original.slice(start + 1, pos),
|
|
195
|
+
end: pos + 1
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function findMainFromClause(scrubbed) {
|
|
199
|
+
const lowerScrubbed = scrubbed.toLowerCase();
|
|
200
|
+
let searchStart = 0;
|
|
201
|
+
const withMatch = /\bwith\s+/i.exec(lowerScrubbed);
|
|
202
|
+
if (withMatch) {
|
|
203
|
+
let depth = 0;
|
|
204
|
+
let pos = withMatch.index + withMatch[0].length;
|
|
205
|
+
while (pos < scrubbed.length) {
|
|
206
|
+
const char = scrubbed[pos];
|
|
207
|
+
if (char === "(") {
|
|
208
|
+
depth++;
|
|
209
|
+
} else if (char === ")") {
|
|
210
|
+
depth--;
|
|
211
|
+
} else if (depth === 0) {
|
|
212
|
+
const remaining = lowerScrubbed.slice(pos);
|
|
213
|
+
if (/^\s*select\s+/i.test(remaining)) {
|
|
214
|
+
searchStart = pos;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
pos++;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const fromPattern = /\bfrom\s+/gi;
|
|
222
|
+
fromPattern.lastIndex = searchStart;
|
|
223
|
+
const fromMatch = fromPattern.exec(lowerScrubbed);
|
|
224
|
+
return fromMatch ? fromMatch.index + fromMatch[0].length : -1;
|
|
225
|
+
}
|
|
226
|
+
function parseTableSources(original, scrubbed) {
|
|
227
|
+
const sources = [];
|
|
228
|
+
const lowerScrubbed = scrubbed.toLowerCase();
|
|
229
|
+
const fromPos = findMainFromClause(scrubbed);
|
|
230
|
+
if (fromPos < 0) {
|
|
231
|
+
return sources;
|
|
232
|
+
}
|
|
233
|
+
const fromTable = parseTableRef(original, scrubbed, fromPos);
|
|
234
|
+
if (fromTable) {
|
|
235
|
+
sources.push(fromTable);
|
|
236
|
+
}
|
|
237
|
+
const joinPattern = /\b(left\s+|right\s+|inner\s+|full\s+|cross\s+)?join\s+/gi;
|
|
238
|
+
joinPattern.lastIndex = fromPos;
|
|
239
|
+
let joinMatch;
|
|
240
|
+
while ((joinMatch = joinPattern.exec(lowerScrubbed)) !== null) {
|
|
241
|
+
const tableStart = joinMatch.index + joinMatch[0].length;
|
|
242
|
+
const joinTable = parseTableRef(original, scrubbed, tableStart);
|
|
243
|
+
if (joinTable) {
|
|
244
|
+
sources.push(joinTable);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return sources;
|
|
248
|
+
}
|
|
249
|
+
function parseTableRef(original, scrubbed, start) {
|
|
250
|
+
let pos = start;
|
|
251
|
+
while (pos < scrubbed.length && isWhitespace(scrubbed[pos])) {
|
|
252
|
+
pos++;
|
|
253
|
+
}
|
|
254
|
+
if (original[pos] !== '"') {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const nameStart = pos;
|
|
258
|
+
const firstIdent = extractQuotedIdentifier(original, pos);
|
|
259
|
+
if (!firstIdent) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
let name = firstIdent.name;
|
|
263
|
+
pos = firstIdent.end;
|
|
264
|
+
let afterName = pos;
|
|
265
|
+
while (afterName < scrubbed.length && isWhitespace(scrubbed[afterName])) {
|
|
266
|
+
afterName++;
|
|
267
|
+
}
|
|
268
|
+
if (scrubbed[afterName] === ".") {
|
|
269
|
+
afterName++;
|
|
270
|
+
while (afterName < scrubbed.length && isWhitespace(scrubbed[afterName])) {
|
|
271
|
+
afterName++;
|
|
272
|
+
}
|
|
273
|
+
if (original[afterName] === '"') {
|
|
274
|
+
const tableIdent = extractQuotedIdentifier(original, afterName);
|
|
275
|
+
if (tableIdent) {
|
|
276
|
+
name = tableIdent.name;
|
|
277
|
+
pos = tableIdent.end;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
let alias;
|
|
282
|
+
let aliasPos = pos;
|
|
283
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
284
|
+
aliasPos++;
|
|
285
|
+
}
|
|
286
|
+
const afterTable = scrubbed.slice(aliasPos).toLowerCase();
|
|
287
|
+
if (afterTable.startsWith("as ")) {
|
|
288
|
+
aliasPos += 3;
|
|
289
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
290
|
+
aliasPos++;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (original[aliasPos] === '"' && !afterTable.startsWith("on ") && !afterTable.startsWith("left ") && !afterTable.startsWith("right ") && !afterTable.startsWith("inner ") && !afterTable.startsWith("full ") && !afterTable.startsWith("cross ") && !afterTable.startsWith("join ") && !afterTable.startsWith("where ") && !afterTable.startsWith("group ") && !afterTable.startsWith("order ") && !afterTable.startsWith("limit ") && !afterTable.startsWith("as ")) {
|
|
294
|
+
const aliasIdent = extractQuotedIdentifier(original, aliasPos);
|
|
295
|
+
if (aliasIdent) {
|
|
296
|
+
alias = aliasIdent.name;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
name,
|
|
301
|
+
alias,
|
|
302
|
+
position: nameStart
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function findJoinClauses(original, scrubbed, sources) {
|
|
306
|
+
const clauses = [];
|
|
307
|
+
const lowerScrubbed = scrubbed.toLowerCase();
|
|
308
|
+
const joinPattern = /\b(left\s+|right\s+|inner\s+|full\s+|cross\s+)?join\s+"[^"]*"(\s*\.\s*"[^"]*")?(\s+as)?(\s+"[^"]*")?\s+on\s+/gi;
|
|
309
|
+
let match;
|
|
310
|
+
let sourceIndex = 1;
|
|
311
|
+
while ((match = joinPattern.exec(lowerScrubbed)) !== null) {
|
|
312
|
+
const joinType = (match[1] || "").trim().toLowerCase();
|
|
313
|
+
const joinKeywordEnd = match.index + (match[1] || "").length + "join".length;
|
|
314
|
+
let tableStart = joinKeywordEnd;
|
|
315
|
+
while (tableStart < original.length && isWhitespace(original[tableStart])) {
|
|
316
|
+
tableStart++;
|
|
317
|
+
}
|
|
318
|
+
const tableIdent = extractQuotedIdentifier(original, tableStart);
|
|
319
|
+
if (!tableIdent)
|
|
320
|
+
continue;
|
|
321
|
+
let tableName = tableIdent.name;
|
|
322
|
+
let afterTable = tableIdent.end;
|
|
323
|
+
let checkPos = afterTable;
|
|
324
|
+
while (checkPos < scrubbed.length && isWhitespace(scrubbed[checkPos])) {
|
|
325
|
+
checkPos++;
|
|
326
|
+
}
|
|
327
|
+
if (scrubbed[checkPos] === ".") {
|
|
328
|
+
checkPos++;
|
|
329
|
+
while (checkPos < scrubbed.length && isWhitespace(scrubbed[checkPos])) {
|
|
330
|
+
checkPos++;
|
|
331
|
+
}
|
|
332
|
+
const realTableIdent = extractQuotedIdentifier(original, checkPos);
|
|
333
|
+
if (realTableIdent) {
|
|
334
|
+
tableName = realTableIdent.name;
|
|
335
|
+
afterTable = realTableIdent.end;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
let tableAlias;
|
|
339
|
+
let aliasPos = afterTable;
|
|
340
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
341
|
+
aliasPos++;
|
|
342
|
+
}
|
|
343
|
+
const afterTableStr = scrubbed.slice(aliasPos).toLowerCase();
|
|
344
|
+
if (afterTableStr.startsWith("as ")) {
|
|
345
|
+
aliasPos += 3;
|
|
346
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
347
|
+
aliasPos++;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (original[aliasPos] === '"' && !afterTableStr.startsWith("on ")) {
|
|
351
|
+
const aliasIdent = extractQuotedIdentifier(original, aliasPos);
|
|
352
|
+
if (aliasIdent) {
|
|
353
|
+
tableAlias = aliasIdent.name;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const onStart = match.index + match[0].length;
|
|
357
|
+
const endPattern = /\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;
|
|
358
|
+
const remaining = lowerScrubbed.slice(onStart);
|
|
359
|
+
const endMatch = endPattern.exec(remaining);
|
|
360
|
+
const onEnd = endMatch ? onStart + endMatch.index : scrubbed.length;
|
|
361
|
+
let leftSource = "";
|
|
362
|
+
if (sourceIndex > 0 && sourceIndex <= sources.length) {
|
|
363
|
+
const prev = sources[sourceIndex - 1];
|
|
364
|
+
leftSource = prev?.alias || prev?.name || "";
|
|
365
|
+
}
|
|
366
|
+
const rightSource = tableAlias || tableName;
|
|
367
|
+
clauses.push({
|
|
368
|
+
joinType,
|
|
369
|
+
tableName,
|
|
370
|
+
tableAlias,
|
|
371
|
+
onStart,
|
|
372
|
+
onEnd,
|
|
373
|
+
leftSource,
|
|
374
|
+
rightSource
|
|
375
|
+
});
|
|
376
|
+
sourceIndex++;
|
|
377
|
+
}
|
|
378
|
+
return clauses;
|
|
379
|
+
}
|
|
380
|
+
function qualifyJoinColumns(query) {
|
|
381
|
+
const lowerQuery = query.toLowerCase();
|
|
382
|
+
if (!lowerQuery.includes("join")) {
|
|
383
|
+
return query;
|
|
384
|
+
}
|
|
385
|
+
const scrubbed = scrubForRewrite(query);
|
|
386
|
+
const sources = parseTableSources(query, scrubbed);
|
|
387
|
+
if (sources.length < 2) {
|
|
388
|
+
return query;
|
|
389
|
+
}
|
|
390
|
+
const joinClauses = findJoinClauses(query, scrubbed, sources);
|
|
391
|
+
if (joinClauses.length === 0) {
|
|
392
|
+
return query;
|
|
393
|
+
}
|
|
394
|
+
let result = query;
|
|
395
|
+
let offset = 0;
|
|
396
|
+
for (const join of joinClauses) {
|
|
397
|
+
const scrubbedOnClause = scrubbed.slice(join.onStart, join.onEnd);
|
|
398
|
+
const originalOnClause = query.slice(join.onStart, join.onEnd);
|
|
399
|
+
let clauseResult = originalOnClause;
|
|
400
|
+
let clauseOffset = 0;
|
|
401
|
+
let eqPos = -1;
|
|
402
|
+
while ((eqPos = scrubbedOnClause.indexOf("=", eqPos + 1)) !== -1) {
|
|
403
|
+
let lhsEnd = eqPos - 1;
|
|
404
|
+
while (lhsEnd >= 0 && isWhitespace(scrubbedOnClause[lhsEnd])) {
|
|
405
|
+
lhsEnd--;
|
|
406
|
+
}
|
|
407
|
+
if (scrubbedOnClause[lhsEnd] !== '"')
|
|
408
|
+
continue;
|
|
409
|
+
let lhsStartPos = lhsEnd - 1;
|
|
410
|
+
while (lhsStartPos >= 0 && scrubbedOnClause[lhsStartPos] !== '"') {
|
|
411
|
+
lhsStartPos--;
|
|
412
|
+
}
|
|
413
|
+
if (lhsStartPos < 0)
|
|
414
|
+
continue;
|
|
415
|
+
const lhsIsQualified = lhsStartPos > 0 && scrubbedOnClause[lhsStartPos - 1] === ".";
|
|
416
|
+
let rhsStartPos = eqPos + 1;
|
|
417
|
+
while (rhsStartPos < scrubbedOnClause.length && isWhitespace(scrubbedOnClause[rhsStartPos])) {
|
|
418
|
+
rhsStartPos++;
|
|
419
|
+
}
|
|
420
|
+
const rhsChar = originalOnClause[rhsStartPos];
|
|
421
|
+
const rhsIsParam = rhsChar === "$";
|
|
422
|
+
const rhsIsStringLiteral = rhsChar === "'";
|
|
423
|
+
const rhsIsColumn = rhsChar === '"';
|
|
424
|
+
if (!rhsIsParam && !rhsIsStringLiteral && !rhsIsColumn)
|
|
425
|
+
continue;
|
|
426
|
+
const rhsIsQualified = !rhsIsColumn || rhsStartPos > 0 && scrubbedOnClause[rhsStartPos - 1] === ".";
|
|
427
|
+
if (lhsIsQualified || rhsIsQualified)
|
|
428
|
+
continue;
|
|
429
|
+
const lhsIdent = extractQuotedIdentifier(originalOnClause, lhsStartPos);
|
|
430
|
+
if (!lhsIdent)
|
|
431
|
+
continue;
|
|
432
|
+
let rhsIdent = null;
|
|
433
|
+
let rhsValue = "";
|
|
434
|
+
let rhsEnd = rhsStartPos;
|
|
435
|
+
if (rhsIsParam) {
|
|
436
|
+
let paramEnd = rhsStartPos + 1;
|
|
437
|
+
while (paramEnd < originalOnClause.length && /\d/.test(originalOnClause[paramEnd])) {
|
|
438
|
+
paramEnd++;
|
|
439
|
+
}
|
|
440
|
+
rhsValue = originalOnClause.slice(rhsStartPos, paramEnd);
|
|
441
|
+
rhsEnd = paramEnd;
|
|
442
|
+
} else if (rhsIsStringLiteral) {
|
|
443
|
+
let literalEnd = rhsStartPos + 1;
|
|
444
|
+
while (literalEnd < originalOnClause.length) {
|
|
445
|
+
if (originalOnClause[literalEnd] === "'") {
|
|
446
|
+
if (originalOnClause[literalEnd + 1] === "'") {
|
|
447
|
+
literalEnd += 2;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
literalEnd++;
|
|
453
|
+
}
|
|
454
|
+
rhsValue = originalOnClause.slice(rhsStartPos, literalEnd + 1);
|
|
455
|
+
rhsEnd = literalEnd + 1;
|
|
456
|
+
} else if (rhsIsColumn) {
|
|
457
|
+
rhsIdent = extractQuotedIdentifier(originalOnClause, rhsStartPos);
|
|
458
|
+
if (rhsIdent) {
|
|
459
|
+
if (rhsIdent.end < scrubbedOnClause.length && scrubbedOnClause[rhsIdent.end] === ".") {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
rhsValue = `"${rhsIdent.name}"`;
|
|
463
|
+
rhsEnd = rhsIdent.end;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (!rhsValue)
|
|
467
|
+
continue;
|
|
468
|
+
if (!rhsIsColumn || !rhsIdent || lhsIdent.name !== rhsIdent.name) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const lhsOriginal = `"${lhsIdent.name}"`;
|
|
472
|
+
let newLhs = lhsOriginal;
|
|
473
|
+
let newRhs = rhsValue;
|
|
474
|
+
if (!lhsIsQualified && join.leftSource) {
|
|
475
|
+
newLhs = `"${join.leftSource}"."${lhsIdent.name}"`;
|
|
476
|
+
}
|
|
477
|
+
if (!rhsIsQualified && rhsIsColumn && rhsIdent && join.rightSource) {
|
|
478
|
+
newRhs = `"${join.rightSource}"."${rhsIdent.name}"`;
|
|
479
|
+
}
|
|
480
|
+
if (newLhs !== lhsOriginal || newRhs !== rhsValue) {
|
|
481
|
+
const opStart = lhsIdent.end;
|
|
482
|
+
let opEnd = opStart;
|
|
483
|
+
while (opEnd < rhsEnd && (isWhitespace(originalOnClause[opEnd]) || originalOnClause[opEnd] === "=")) {
|
|
484
|
+
opEnd++;
|
|
485
|
+
}
|
|
486
|
+
const operator = originalOnClause.slice(opStart, opEnd);
|
|
487
|
+
const newExpr = `${newLhs}${operator}${newRhs}`;
|
|
488
|
+
const oldExprLength = rhsEnd - lhsStartPos;
|
|
489
|
+
clauseResult = clauseResult.slice(0, lhsStartPos + clauseOffset) + newExpr + clauseResult.slice(lhsStartPos + oldExprLength + clauseOffset);
|
|
490
|
+
clauseOffset += newExpr.length - oldExprLength;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (clauseResult !== originalOnClause) {
|
|
494
|
+
result = result.slice(0, join.onStart + offset) + clauseResult + result.slice(join.onEnd + offset);
|
|
495
|
+
offset += clauseResult.length - originalOnClause.length;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
178
500
|
|
|
179
501
|
// src/sql/result-mapper.ts
|
|
180
502
|
import {
|
|
@@ -815,8 +1137,19 @@ function rewriteQuery(mode, query) {
|
|
|
815
1137
|
if (mode === "never") {
|
|
816
1138
|
return { sql: query, rewritten: false };
|
|
817
1139
|
}
|
|
818
|
-
|
|
819
|
-
|
|
1140
|
+
let result = query;
|
|
1141
|
+
let wasRewritten = false;
|
|
1142
|
+
const arrayRewritten = adaptArrayOperators(result);
|
|
1143
|
+
if (arrayRewritten !== result) {
|
|
1144
|
+
result = arrayRewritten;
|
|
1145
|
+
wasRewritten = true;
|
|
1146
|
+
}
|
|
1147
|
+
const joinQualified = qualifyJoinColumns(result);
|
|
1148
|
+
if (joinQualified !== result) {
|
|
1149
|
+
result = joinQualified;
|
|
1150
|
+
wasRewritten = true;
|
|
1151
|
+
}
|
|
1152
|
+
return { sql: result, rewritten: wasRewritten };
|
|
820
1153
|
}
|
|
821
1154
|
|
|
822
1155
|
class DuckDBPreparedQuery extends PgPreparedQuery {
|
|
@@ -1,2 +1,14 @@
|
|
|
1
1
|
export declare function scrubForRewrite(query: string): string;
|
|
2
2
|
export declare function adaptArrayOperators(query: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Qualifies unqualified column references in JOIN ON clauses.
|
|
5
|
+
*
|
|
6
|
+
* Transforms patterns like:
|
|
7
|
+
* `left join "b" on "col" = "col"`
|
|
8
|
+
* To:
|
|
9
|
+
* `left join "b" on "a"."col" = "b"."col"`
|
|
10
|
+
*
|
|
11
|
+
* This fixes the issue where drizzle-orm generates unqualified column
|
|
12
|
+
* references when joining CTEs with eq().
|
|
13
|
+
*/
|
|
14
|
+
export declare function qualifyJoinColumns(query: string): string;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"module": "./dist/index.mjs",
|
|
4
4
|
"main": "./dist/index.mjs",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
|
-
"version": "1.1.
|
|
6
|
+
"version": "1.1.3",
|
|
7
7
|
"description": "A drizzle ORM client for use with DuckDB. Based on drizzle's Postgres client.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"scripts": {
|
package/src/session.ts
CHANGED
|
@@ -15,7 +15,10 @@ import type {
|
|
|
15
15
|
} from 'drizzle-orm/relations';
|
|
16
16
|
import { fillPlaceholders, type Query, SQL, sql } from 'drizzle-orm/sql/sql';
|
|
17
17
|
import type { Assume } from 'drizzle-orm/utils';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
adaptArrayOperators,
|
|
20
|
+
qualifyJoinColumns,
|
|
21
|
+
} from './sql/query-rewriters.ts';
|
|
19
22
|
import { mapResultRow } from './sql/result-mapper.ts';
|
|
20
23
|
import { TransactionRollbackError } from 'drizzle-orm/errors';
|
|
21
24
|
import type { DuckDBDialect } from './dialect.ts';
|
|
@@ -61,8 +64,24 @@ function rewriteQuery(
|
|
|
61
64
|
return { sql: query, rewritten: false };
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
let result = query;
|
|
68
|
+
let wasRewritten = false;
|
|
69
|
+
|
|
70
|
+
// Rewrite Postgres array operators to DuckDB functions
|
|
71
|
+
const arrayRewritten = adaptArrayOperators(result);
|
|
72
|
+
if (arrayRewritten !== result) {
|
|
73
|
+
result = arrayRewritten;
|
|
74
|
+
wasRewritten = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Qualify unqualified column references in JOIN ON clauses
|
|
78
|
+
const joinQualified = qualifyJoinColumns(result);
|
|
79
|
+
if (joinQualified !== result) {
|
|
80
|
+
result = joinQualified;
|
|
81
|
+
wasRewritten = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { sql: result, rewritten: wasRewritten };
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
export class DuckDBPreparedQuery<
|
|
@@ -211,3 +211,498 @@ export function adaptArrayOperators(query: string): string {
|
|
|
211
211
|
|
|
212
212
|
return rewritten;
|
|
213
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
|
+
*/
|
|
237
|
+
function extractQuotedIdentifier(
|
|
238
|
+
original: string,
|
|
239
|
+
start: number
|
|
240
|
+
): { name: string; end: number } | null {
|
|
241
|
+
if (original[start] !== '"') {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
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;
|
|
251
|
+
}
|
|
252
|
+
pos++;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (pos >= original.length) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
name: original.slice(start + 1, pos),
|
|
261
|
+
end: pos + 1,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Finds the main SELECT's FROM clause, skipping any FROM inside CTEs.
|
|
267
|
+
*/
|
|
268
|
+
function findMainFromClause(scrubbed: string): number {
|
|
269
|
+
const lowerScrubbed = scrubbed.toLowerCase();
|
|
270
|
+
|
|
271
|
+
// If there's a WITH clause, find where the CTEs end
|
|
272
|
+
let searchStart = 0;
|
|
273
|
+
const withMatch = /\bwith\s+/i.exec(lowerScrubbed);
|
|
274
|
+
if (withMatch) {
|
|
275
|
+
// Find the main SELECT after the CTEs
|
|
276
|
+
// CTEs are separated by commas and end with the main SELECT
|
|
277
|
+
let depth = 0;
|
|
278
|
+
let pos = withMatch.index + withMatch[0].length;
|
|
279
|
+
|
|
280
|
+
while (pos < scrubbed.length) {
|
|
281
|
+
const char = scrubbed[pos];
|
|
282
|
+
if (char === '(') {
|
|
283
|
+
depth++;
|
|
284
|
+
} else if (char === ')') {
|
|
285
|
+
depth--;
|
|
286
|
+
} else if (depth === 0) {
|
|
287
|
+
// Check if we're at "select" keyword (main query)
|
|
288
|
+
const remaining = lowerScrubbed.slice(pos);
|
|
289
|
+
if (/^\s*select\s+/i.test(remaining)) {
|
|
290
|
+
searchStart = pos;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
pos++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Find FROM after the main SELECT
|
|
299
|
+
const fromPattern = /\bfrom\s+/gi;
|
|
300
|
+
fromPattern.lastIndex = searchStart;
|
|
301
|
+
const fromMatch = fromPattern.exec(lowerScrubbed);
|
|
302
|
+
|
|
303
|
+
return fromMatch ? fromMatch.index + fromMatch[0].length : -1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Parses table sources from FROM and JOIN clauses.
|
|
308
|
+
* Returns an array of table sources in order of appearance.
|
|
309
|
+
*/
|
|
310
|
+
function parseTableSources(original: string, scrubbed: string): TableSource[] {
|
|
311
|
+
const sources: TableSource[] = [];
|
|
312
|
+
const lowerScrubbed = scrubbed.toLowerCase();
|
|
313
|
+
|
|
314
|
+
// Find the main FROM clause (after CTEs if present)
|
|
315
|
+
const fromPos = findMainFromClause(scrubbed);
|
|
316
|
+
if (fromPos < 0) {
|
|
317
|
+
return sources;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const fromTable = parseTableRef(original, scrubbed, fromPos);
|
|
321
|
+
if (fromTable) {
|
|
322
|
+
sources.push(fromTable);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const joinPattern =
|
|
326
|
+
/\b(left\s+|right\s+|inner\s+|full\s+|cross\s+)?join\s+/gi;
|
|
327
|
+
joinPattern.lastIndex = fromPos;
|
|
328
|
+
|
|
329
|
+
let joinMatch;
|
|
330
|
+
while ((joinMatch = joinPattern.exec(lowerScrubbed)) !== null) {
|
|
331
|
+
const tableStart = joinMatch.index + joinMatch[0].length;
|
|
332
|
+
const joinTable = parseTableRef(original, scrubbed, tableStart);
|
|
333
|
+
if (joinTable) {
|
|
334
|
+
sources.push(joinTable);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return sources;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Parses a table reference (potentially with alias) starting at the given position.
|
|
343
|
+
* Handles: "table", "table" "alias", "table" as "alias", "schema"."table"
|
|
344
|
+
*/
|
|
345
|
+
function parseTableRef(
|
|
346
|
+
original: string,
|
|
347
|
+
scrubbed: string,
|
|
348
|
+
start: number
|
|
349
|
+
): TableSource | null {
|
|
350
|
+
let pos = start;
|
|
351
|
+
while (pos < scrubbed.length && isWhitespace(scrubbed[pos])) {
|
|
352
|
+
pos++;
|
|
353
|
+
}
|
|
354
|
+
if (original[pos] !== '"') {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const nameStart = pos;
|
|
359
|
+
const firstIdent = extractQuotedIdentifier(original, pos);
|
|
360
|
+
if (!firstIdent) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let name = firstIdent.name;
|
|
365
|
+
pos = firstIdent.end;
|
|
366
|
+
|
|
367
|
+
// Check for schema.table pattern
|
|
368
|
+
let afterName = pos;
|
|
369
|
+
while (afterName < scrubbed.length && isWhitespace(scrubbed[afterName])) {
|
|
370
|
+
afterName++;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (scrubbed[afterName] === '.') {
|
|
374
|
+
afterName++;
|
|
375
|
+
while (afterName < scrubbed.length && isWhitespace(scrubbed[afterName])) {
|
|
376
|
+
afterName++;
|
|
377
|
+
}
|
|
378
|
+
if (original[afterName] === '"') {
|
|
379
|
+
const tableIdent = extractQuotedIdentifier(original, afterName);
|
|
380
|
+
if (tableIdent) {
|
|
381
|
+
name = tableIdent.name;
|
|
382
|
+
pos = tableIdent.end;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let alias: string | undefined;
|
|
388
|
+
let aliasPos = pos;
|
|
389
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
390
|
+
aliasPos++;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const afterTable = scrubbed.slice(aliasPos).toLowerCase();
|
|
394
|
+
if (afterTable.startsWith('as ')) {
|
|
395
|
+
aliasPos += 3;
|
|
396
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
397
|
+
aliasPos++;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (
|
|
402
|
+
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 ')
|
|
415
|
+
) {
|
|
416
|
+
const aliasIdent = extractQuotedIdentifier(original, aliasPos);
|
|
417
|
+
if (aliasIdent) {
|
|
418
|
+
alias = aliasIdent.name;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
name,
|
|
424
|
+
alias,
|
|
425
|
+
position: nameStart,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Finds all JOIN clauses with their ON clause boundaries.
|
|
431
|
+
*/
|
|
432
|
+
function findJoinClauses(
|
|
433
|
+
original: string,
|
|
434
|
+
scrubbed: string,
|
|
435
|
+
sources: TableSource[]
|
|
436
|
+
): JoinClause[] {
|
|
437
|
+
const clauses: JoinClause[] = [];
|
|
438
|
+
const lowerScrubbed = scrubbed.toLowerCase();
|
|
439
|
+
|
|
440
|
+
// Pattern to find JOINs with ON clauses
|
|
441
|
+
// Use scrubbed for matching, original for extracting values
|
|
442
|
+
// Handle optional schema prefix: "schema"."table" or just "table"
|
|
443
|
+
const joinPattern =
|
|
444
|
+
/\b(left\s+|right\s+|inner\s+|full\s+|cross\s+)?join\s+"[^"]*"(\s*\.\s*"[^"]*")?(\s+as)?(\s+"[^"]*")?\s+on\s+/gi;
|
|
445
|
+
|
|
446
|
+
let match;
|
|
447
|
+
let sourceIndex = 1; // Start at 1 since index 0 is the FROM table
|
|
448
|
+
|
|
449
|
+
while ((match = joinPattern.exec(lowerScrubbed)) !== null) {
|
|
450
|
+
const joinType = (match[1] || '').trim().toLowerCase();
|
|
451
|
+
const joinKeywordEnd =
|
|
452
|
+
match.index + (match[1] || '').length + 'join'.length;
|
|
453
|
+
let tableStart = joinKeywordEnd;
|
|
454
|
+
while (tableStart < original.length && isWhitespace(original[tableStart])) {
|
|
455
|
+
tableStart++;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const tableIdent = extractQuotedIdentifier(original, tableStart);
|
|
459
|
+
if (!tableIdent) continue;
|
|
460
|
+
|
|
461
|
+
let tableName = tableIdent.name;
|
|
462
|
+
let afterTable = tableIdent.end;
|
|
463
|
+
let checkPos = afterTable;
|
|
464
|
+
while (checkPos < scrubbed.length && isWhitespace(scrubbed[checkPos])) {
|
|
465
|
+
checkPos++;
|
|
466
|
+
}
|
|
467
|
+
if (scrubbed[checkPos] === '.') {
|
|
468
|
+
checkPos++;
|
|
469
|
+
while (checkPos < scrubbed.length && isWhitespace(scrubbed[checkPos])) {
|
|
470
|
+
checkPos++;
|
|
471
|
+
}
|
|
472
|
+
const realTableIdent = extractQuotedIdentifier(original, checkPos);
|
|
473
|
+
if (realTableIdent) {
|
|
474
|
+
tableName = realTableIdent.name;
|
|
475
|
+
afterTable = realTableIdent.end;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let tableAlias: string | undefined;
|
|
480
|
+
let aliasPos = afterTable;
|
|
481
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
482
|
+
aliasPos++;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const afterTableStr = scrubbed.slice(aliasPos).toLowerCase();
|
|
486
|
+
if (afterTableStr.startsWith('as ')) {
|
|
487
|
+
aliasPos += 3;
|
|
488
|
+
while (aliasPos < scrubbed.length && isWhitespace(scrubbed[aliasPos])) {
|
|
489
|
+
aliasPos++;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (original[aliasPos] === '"' && !afterTableStr.startsWith('on ')) {
|
|
494
|
+
const aliasIdent = extractQuotedIdentifier(original, aliasPos);
|
|
495
|
+
if (aliasIdent) {
|
|
496
|
+
tableAlias = aliasIdent.name;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const onStart = match.index + match[0].length;
|
|
501
|
+
|
|
502
|
+
// Find the end of the ON clause (next JOIN, WHERE, GROUP, ORDER, LIMIT, or end)
|
|
503
|
+
const endPattern =
|
|
504
|
+
/\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;
|
|
505
|
+
const remaining = lowerScrubbed.slice(onStart);
|
|
506
|
+
const endMatch = endPattern.exec(remaining);
|
|
507
|
+
const onEnd = endMatch ? onStart + endMatch.index : scrubbed.length;
|
|
508
|
+
|
|
509
|
+
// Determine the left source (previous table/CTE)
|
|
510
|
+
let leftSource = '';
|
|
511
|
+
if (sourceIndex > 0 && sourceIndex <= sources.length) {
|
|
512
|
+
const prev = sources[sourceIndex - 1];
|
|
513
|
+
leftSource = prev?.alias || prev?.name || '';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const rightSource = tableAlias || tableName;
|
|
517
|
+
|
|
518
|
+
clauses.push({
|
|
519
|
+
joinType,
|
|
520
|
+
tableName,
|
|
521
|
+
tableAlias,
|
|
522
|
+
onStart,
|
|
523
|
+
onEnd,
|
|
524
|
+
leftSource,
|
|
525
|
+
rightSource,
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
sourceIndex++;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return clauses;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Qualifies unqualified column references in JOIN ON clauses.
|
|
536
|
+
*
|
|
537
|
+
* Transforms patterns like:
|
|
538
|
+
* `left join "b" on "col" = "col"`
|
|
539
|
+
* To:
|
|
540
|
+
* `left join "b" on "a"."col" = "b"."col"`
|
|
541
|
+
*
|
|
542
|
+
* This fixes the issue where drizzle-orm generates unqualified column
|
|
543
|
+
* references when joining CTEs with eq().
|
|
544
|
+
*/
|
|
545
|
+
export function qualifyJoinColumns(query: string): string {
|
|
546
|
+
const lowerQuery = query.toLowerCase();
|
|
547
|
+
if (!lowerQuery.includes('join')) {
|
|
548
|
+
return query;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const scrubbed = scrubForRewrite(query);
|
|
552
|
+
const sources = parseTableSources(query, scrubbed);
|
|
553
|
+
if (sources.length < 2) {
|
|
554
|
+
return query;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const joinClauses = findJoinClauses(query, scrubbed, sources);
|
|
558
|
+
|
|
559
|
+
if (joinClauses.length === 0) {
|
|
560
|
+
return query;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let result = query;
|
|
564
|
+
let offset = 0;
|
|
565
|
+
|
|
566
|
+
for (const join of joinClauses) {
|
|
567
|
+
const scrubbedOnClause = scrubbed.slice(join.onStart, join.onEnd);
|
|
568
|
+
const originalOnClause = query.slice(join.onStart, join.onEnd);
|
|
569
|
+
let clauseResult = originalOnClause;
|
|
570
|
+
let clauseOffset = 0;
|
|
571
|
+
let eqPos = -1;
|
|
572
|
+
while ((eqPos = scrubbedOnClause.indexOf('=', eqPos + 1)) !== -1) {
|
|
573
|
+
let lhsEnd = eqPos - 1;
|
|
574
|
+
while (lhsEnd >= 0 && isWhitespace(scrubbedOnClause[lhsEnd])) {
|
|
575
|
+
lhsEnd--;
|
|
576
|
+
}
|
|
577
|
+
if (scrubbedOnClause[lhsEnd] !== '"') continue;
|
|
578
|
+
|
|
579
|
+
let lhsStartPos = lhsEnd - 1;
|
|
580
|
+
while (lhsStartPos >= 0 && scrubbedOnClause[lhsStartPos] !== '"') {
|
|
581
|
+
lhsStartPos--;
|
|
582
|
+
}
|
|
583
|
+
if (lhsStartPos < 0) continue;
|
|
584
|
+
|
|
585
|
+
const lhsIsQualified =
|
|
586
|
+
lhsStartPos > 0 && scrubbedOnClause[lhsStartPos - 1] === '.';
|
|
587
|
+
let rhsStartPos = eqPos + 1;
|
|
588
|
+
while (
|
|
589
|
+
rhsStartPos < scrubbedOnClause.length &&
|
|
590
|
+
isWhitespace(scrubbedOnClause[rhsStartPos])
|
|
591
|
+
) {
|
|
592
|
+
rhsStartPos++;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const rhsChar = originalOnClause[rhsStartPos];
|
|
596
|
+
const rhsIsParam = rhsChar === '$';
|
|
597
|
+
const rhsIsStringLiteral = rhsChar === "'";
|
|
598
|
+
const rhsIsColumn = rhsChar === '"';
|
|
599
|
+
|
|
600
|
+
if (!rhsIsParam && !rhsIsStringLiteral && !rhsIsColumn) continue;
|
|
601
|
+
|
|
602
|
+
const rhsIsQualified =
|
|
603
|
+
!rhsIsColumn ||
|
|
604
|
+
(rhsStartPos > 0 && scrubbedOnClause[rhsStartPos - 1] === '.');
|
|
605
|
+
if (lhsIsQualified || rhsIsQualified) continue;
|
|
606
|
+
|
|
607
|
+
const lhsIdent = extractQuotedIdentifier(originalOnClause, lhsStartPos);
|
|
608
|
+
if (!lhsIdent) continue;
|
|
609
|
+
|
|
610
|
+
let rhsIdent: { name: string; end: number } | null = null;
|
|
611
|
+
let rhsValue = '';
|
|
612
|
+
let rhsEnd = rhsStartPos;
|
|
613
|
+
|
|
614
|
+
if (rhsIsParam) {
|
|
615
|
+
let paramEnd = rhsStartPos + 1;
|
|
616
|
+
while (
|
|
617
|
+
paramEnd < originalOnClause.length &&
|
|
618
|
+
/\d/.test(originalOnClause[paramEnd]!)
|
|
619
|
+
) {
|
|
620
|
+
paramEnd++;
|
|
621
|
+
}
|
|
622
|
+
rhsValue = originalOnClause.slice(rhsStartPos, paramEnd);
|
|
623
|
+
rhsEnd = paramEnd;
|
|
624
|
+
} else if (rhsIsStringLiteral) {
|
|
625
|
+
let literalEnd = rhsStartPos + 1;
|
|
626
|
+
while (literalEnd < originalOnClause.length) {
|
|
627
|
+
if (originalOnClause[literalEnd] === "'") {
|
|
628
|
+
if (originalOnClause[literalEnd + 1] === "'") {
|
|
629
|
+
literalEnd += 2;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
literalEnd++;
|
|
635
|
+
}
|
|
636
|
+
rhsValue = originalOnClause.slice(rhsStartPos, literalEnd + 1);
|
|
637
|
+
rhsEnd = literalEnd + 1;
|
|
638
|
+
} else if (rhsIsColumn) {
|
|
639
|
+
rhsIdent = extractQuotedIdentifier(originalOnClause, rhsStartPos);
|
|
640
|
+
if (rhsIdent) {
|
|
641
|
+
// Check if this identifier is followed by a dot (meaning it's a table prefix, not the column)
|
|
642
|
+
if (
|
|
643
|
+
rhsIdent.end < scrubbedOnClause.length &&
|
|
644
|
+
scrubbedOnClause[rhsIdent.end] === '.'
|
|
645
|
+
) {
|
|
646
|
+
// This is a qualified reference "table"."column" - skip, it's already qualified
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
rhsValue = `"${rhsIdent.name}"`;
|
|
650
|
+
rhsEnd = rhsIdent.end;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (!rhsValue) continue;
|
|
655
|
+
|
|
656
|
+
// Only qualify when both sides are columns with the same name.
|
|
657
|
+
// Only same-named columns cause "Ambiguous reference" errors in DuckDB.
|
|
658
|
+
if (!rhsIsColumn || !rhsIdent || lhsIdent.name !== rhsIdent.name) {
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const lhsOriginal = `"${lhsIdent.name}"`;
|
|
663
|
+
let newLhs = lhsOriginal;
|
|
664
|
+
let newRhs = rhsValue;
|
|
665
|
+
|
|
666
|
+
if (!lhsIsQualified && join.leftSource) {
|
|
667
|
+
newLhs = `"${join.leftSource}"."${lhsIdent.name}"`;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (!rhsIsQualified && rhsIsColumn && rhsIdent && join.rightSource) {
|
|
671
|
+
newRhs = `"${join.rightSource}"."${rhsIdent.name}"`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (newLhs !== lhsOriginal || newRhs !== rhsValue) {
|
|
675
|
+
const opStart = lhsIdent.end;
|
|
676
|
+
let opEnd = opStart;
|
|
677
|
+
while (
|
|
678
|
+
opEnd < rhsEnd &&
|
|
679
|
+
(isWhitespace(originalOnClause[opEnd]) ||
|
|
680
|
+
originalOnClause[opEnd] === '=')
|
|
681
|
+
) {
|
|
682
|
+
opEnd++;
|
|
683
|
+
}
|
|
684
|
+
const operator = originalOnClause.slice(opStart, opEnd);
|
|
685
|
+
|
|
686
|
+
const newExpr = `${newLhs}${operator}${newRhs}`;
|
|
687
|
+
const oldExprLength = rhsEnd - lhsStartPos;
|
|
688
|
+
|
|
689
|
+
clauseResult =
|
|
690
|
+
clauseResult.slice(0, lhsStartPos + clauseOffset) +
|
|
691
|
+
newExpr +
|
|
692
|
+
clauseResult.slice(lhsStartPos + oldExprLength + clauseOffset);
|
|
693
|
+
|
|
694
|
+
clauseOffset += newExpr.length - oldExprLength;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (clauseResult !== originalOnClause) {
|
|
699
|
+
result =
|
|
700
|
+
result.slice(0, join.onStart + offset) +
|
|
701
|
+
clauseResult +
|
|
702
|
+
result.slice(join.onEnd + offset);
|
|
703
|
+
offset += clauseResult.length - originalOnClause.length;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return result;
|
|
708
|
+
}
|