sqlstack 1.0.17 → 1.0.19

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.
@@ -276,6 +276,767 @@ export function createBinder(dialect) {
276
276
  i = updateEnd;
277
277
  continue;
278
278
  }
279
+ // Check for :insert(...) syntax (case-insensitive)
280
+ const insertMatch = /^:insert\s*\(/i.exec(sql.slice(i));
281
+ if (insertMatch) {
282
+ if (isArrayArgs) {
283
+ throw new Error(':insert() syntax only works with named parameters');
284
+ }
285
+ // Parse the column list inside parentheses
286
+ let j = i + insertMatch[0].length; // Skip ':insert('
287
+ const columnStart = j;
288
+ let parenDepth = 1;
289
+ let inString = false;
290
+ let stringChar = '';
291
+ // Find matching closing parenthesis
292
+ while (j < len && parenDepth > 0) {
293
+ const c = sql[j];
294
+ const cNext = j + 1 < len ? sql[j + 1] : '';
295
+ if (!inString) {
296
+ if (c === "'" || c === '"') {
297
+ inString = true;
298
+ stringChar = c;
299
+ }
300
+ else if (c === '(') {
301
+ parenDepth++;
302
+ }
303
+ else if (c === ')') {
304
+ parenDepth--;
305
+ }
306
+ }
307
+ else {
308
+ if (c === stringChar) {
309
+ // Check for escaped quote
310
+ if (cNext === stringChar) {
311
+ j++; // Skip escaped quote
312
+ }
313
+ else {
314
+ inString = false;
315
+ }
316
+ }
317
+ }
318
+ if (parenDepth > 0)
319
+ j++;
320
+ }
321
+ if (parenDepth > 0) {
322
+ throw new Error('Unclosed :insert() parentheses');
323
+ }
324
+ // Extract column list (between the parentheses)
325
+ const columnList = sql.slice(columnStart, j).trim();
326
+ const insertEnd = j + 1; // Include closing ')'
327
+ // Parse column list (split by comma, respecting quotes and parentheses)
328
+ const items = [];
329
+ let current = '';
330
+ let inSQ = false; // single quote string
331
+ let inDQ = false; // double quote identifier
332
+ let itemParenDepth = 0;
333
+ for (let k = 0; k < columnList.length; k++) {
334
+ const ch = columnList[k];
335
+ const next = k + 1 < columnList.length ? columnList[k + 1] : '';
336
+ if (inSQ) {
337
+ current += ch;
338
+ if (ch === "'" && next === "'") {
339
+ current += next;
340
+ k++;
341
+ }
342
+ else if (ch === "'") {
343
+ inSQ = false;
344
+ }
345
+ continue;
346
+ }
347
+ if (inDQ) {
348
+ current += ch;
349
+ if (ch === '"' && next === '"') {
350
+ current += next;
351
+ k++;
352
+ }
353
+ else if (ch === '"') {
354
+ inDQ = false;
355
+ }
356
+ continue;
357
+ }
358
+ if (ch === "'") {
359
+ current += ch;
360
+ inSQ = true;
361
+ continue;
362
+ }
363
+ if (ch === '"') {
364
+ current += ch;
365
+ inDQ = true;
366
+ continue;
367
+ }
368
+ if (ch === '(') {
369
+ current += ch;
370
+ itemParenDepth++;
371
+ continue;
372
+ }
373
+ if (ch === ')') {
374
+ current += ch;
375
+ itemParenDepth--;
376
+ continue;
377
+ }
378
+ if (ch === ',' && itemParenDepth === 0) {
379
+ const trimmed = current.trim();
380
+ if (trimmed.length > 0) {
381
+ items.push(trimmed);
382
+ }
383
+ current = '';
384
+ continue;
385
+ }
386
+ current += ch;
387
+ }
388
+ // Add the last item
389
+ const trimmed = current.trim();
390
+ if (trimmed.length > 0) {
391
+ items.push(trimmed);
392
+ }
393
+ if (items.length === 0) {
394
+ throw new Error(':insert() requires at least one column name');
395
+ }
396
+ // Build column list and VALUES clause
397
+ const obj = args;
398
+ const columnNames = [];
399
+ const valueParts = [];
400
+ const insertParams = [];
401
+ let hasParameterized = false;
402
+ for (const item of items) {
403
+ const trimmed = item.trim();
404
+ // Check if this is a literal expression (contains = not inside quotes)
405
+ let isLiteral = false;
406
+ let eqIndex = -1;
407
+ let inString = false;
408
+ let stringChar = '';
409
+ for (let k = 0; k < trimmed.length; k++) {
410
+ const ch = trimmed[k];
411
+ const next = k + 1 < trimmed.length ? trimmed[k + 1] : '';
412
+ if (!inString && (ch === "'" || ch === '"')) {
413
+ inString = true;
414
+ stringChar = ch;
415
+ }
416
+ else if (inString) {
417
+ if (ch === stringChar) {
418
+ if (next === stringChar) {
419
+ k++; // Skip escaped quote
420
+ }
421
+ else {
422
+ inString = false;
423
+ }
424
+ }
425
+ }
426
+ else if (ch === '=') {
427
+ eqIndex = k;
428
+ isLiteral = true;
429
+ break;
430
+ }
431
+ }
432
+ if (isLiteral && eqIndex >= 0) {
433
+ // Literal SQL expression - extract column name and value
434
+ const colName = trimmed.slice(0, eqIndex).trim();
435
+ const literalValue = trimmed.slice(eqIndex + 1).trim();
436
+ columnNames.push(colName);
437
+ valueParts.push(literalValue);
438
+ }
439
+ else {
440
+ // Parameterized column - check if defined in args
441
+ hasParameterized = true;
442
+ if (Object.prototype.hasOwnProperty.call(obj, trimmed) && obj[trimmed] !== undefined) {
443
+ columnNames.push(trimmed);
444
+ valueParts.push(placeholder(pgIndex));
445
+ insertParams.push(obj[trimmed]);
446
+ }
447
+ }
448
+ }
449
+ // Only throw error if all parameterized columns are undefined (literals are always included)
450
+ if (hasParameterized && columnNames.length === 0) {
451
+ throw new Error('All columns in :insert() are undefined');
452
+ }
453
+ if (columnNames.length === 0) {
454
+ throw new Error(':insert() requires at least one column name');
455
+ }
456
+ // Replace :insert(...) with column list and VALUES clause
457
+ out.push('(', columnNames.join(', '), ') VALUES (', valueParts.join(', '), ')');
458
+ params.push(...insertParams);
459
+ i = insertEnd;
460
+ continue;
461
+ }
462
+ // Check for :batch_insert(...) syntax (case-insensitive)
463
+ const batchInsertMatch = /^:batch_insert\s*\(/i.exec(sql.slice(i));
464
+ if (batchInsertMatch) {
465
+ if (isArrayArgs) {
466
+ throw new Error(':batch_insert() syntax only works with named parameters');
467
+ }
468
+ // Parse the argument list inside parentheses
469
+ let j = i + batchInsertMatch[0].length; // Skip ':batch_insert('
470
+ const argStart = j;
471
+ let parenDepth = 1;
472
+ let inString = false;
473
+ let stringChar = '';
474
+ // Find matching closing parenthesis
475
+ while (j < len && parenDepth > 0) {
476
+ const c = sql[j];
477
+ const cNext = j + 1 < len ? sql[j + 1] : '';
478
+ if (!inString) {
479
+ if (c === "'" || c === '"') {
480
+ inString = true;
481
+ stringChar = c;
482
+ }
483
+ else if (c === '(') {
484
+ parenDepth++;
485
+ }
486
+ else if (c === ')') {
487
+ parenDepth--;
488
+ }
489
+ }
490
+ else {
491
+ if (c === stringChar) {
492
+ // Check for escaped quote
493
+ if (cNext === stringChar) {
494
+ j++; // Skip escaped quote
495
+ }
496
+ else {
497
+ inString = false;
498
+ }
499
+ }
500
+ }
501
+ if (parenDepth > 0)
502
+ j++;
503
+ }
504
+ if (parenDepth > 0) {
505
+ throw new Error('Unclosed :batch_insert() parentheses');
506
+ }
507
+ // Extract argument list (between the parentheses)
508
+ const argList = sql.slice(argStart, j).trim();
509
+ const batchInsertEnd = j + 1; // Include closing ')'
510
+ // Parse argument list (split by comma, respecting quotes and parentheses)
511
+ const items = [];
512
+ let current = '';
513
+ let inSQ = false; // single quote string
514
+ let inDQ = false; // double quote identifier
515
+ let itemParenDepth = 0;
516
+ for (let k = 0; k < argList.length; k++) {
517
+ const ch2 = argList[k];
518
+ const next2 = k + 1 < argList.length ? argList[k + 1] : '';
519
+ if (inSQ) {
520
+ current += ch2;
521
+ if (ch2 === "'" && next2 === "'") {
522
+ current += next2;
523
+ k++;
524
+ }
525
+ else if (ch2 === "'") {
526
+ inSQ = false;
527
+ }
528
+ continue;
529
+ }
530
+ if (inDQ) {
531
+ current += ch2;
532
+ if (ch2 === '"' && next2 === '"') {
533
+ current += next2;
534
+ k++;
535
+ }
536
+ else if (ch2 === '"') {
537
+ inDQ = false;
538
+ }
539
+ continue;
540
+ }
541
+ if (ch2 === "'") {
542
+ current += ch2;
543
+ inSQ = true;
544
+ continue;
545
+ }
546
+ if (ch2 === '"') {
547
+ current += ch2;
548
+ inDQ = true;
549
+ continue;
550
+ }
551
+ if (ch2 === '(') {
552
+ current += ch2;
553
+ itemParenDepth++;
554
+ continue;
555
+ }
556
+ if (ch2 === ')') {
557
+ current += ch2;
558
+ itemParenDepth--;
559
+ continue;
560
+ }
561
+ if (ch2 === ',' && itemParenDepth === 0) {
562
+ const trimmedItem = current.trim();
563
+ if (trimmedItem.length > 0) {
564
+ items.push(trimmedItem);
565
+ }
566
+ current = '';
567
+ continue;
568
+ }
569
+ current += ch2;
570
+ }
571
+ // Add the last item
572
+ const trimmedItem = current.trim();
573
+ if (trimmedItem.length > 0) {
574
+ items.push(trimmedItem);
575
+ }
576
+ if (items.length === 0) {
577
+ throw new Error(':batch_insert() requires at least one argument');
578
+ }
579
+ const obj = args;
580
+ const batchArgs = [];
581
+ const resolvePath = (root, path) => {
582
+ const segments = path.split('.').map((s) => s.trim()).filter((s) => s.length > 0);
583
+ if (segments.length === 0) {
584
+ throw new Error(`Invalid path "${path}" in :batch_insert()`);
585
+ }
586
+ const nestedArrayError = () => {
587
+ throw new Error('Nested arrays are not supported in :batch_insert()');
588
+ };
589
+ const descend = (value, idx) => {
590
+ if (idx >= segments.length) {
591
+ if (Array.isArray(value)) {
592
+ // At leaf: array-of-arrays is not allowed
593
+ for (const v of value) {
594
+ if (Array.isArray(v))
595
+ nestedArrayError();
596
+ }
597
+ return { isArray: true, values: value.slice() };
598
+ }
599
+ return { isArray: false, values: [value] };
600
+ }
601
+ const key = segments[idx];
602
+ if (Array.isArray(value)) {
603
+ // We are traversing through an array: map remaining path over each element
604
+ const rowValues = [];
605
+ for (const element of value) {
606
+ const sub = descend(element, idx);
607
+ if (sub.isArray)
608
+ nestedArrayError();
609
+ rowValues.push(sub.values[0]);
610
+ }
611
+ return { isArray: true, values: rowValues };
612
+ }
613
+ let nextVal;
614
+ if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key)) {
615
+ nextVal = value[key];
616
+ }
617
+ else {
618
+ nextVal = undefined;
619
+ }
620
+ return descend(nextVal, idx + 1);
621
+ };
622
+ return descend(root, 0);
623
+ };
624
+ for (const item of items) {
625
+ const path = item.trim();
626
+ const firstSeg = path.split('.')[0];
627
+ if (!Object.prototype.hasOwnProperty.call(obj, firstSeg)) {
628
+ throw new Error(`Path "${path}" in :batch_insert() refers to a missing key`);
629
+ }
630
+ const { isArray, values } = resolvePath(obj, path);
631
+ // Undefined is not allowed for any value
632
+ for (const v of values) {
633
+ if (v === undefined) {
634
+ throw new Error(`Path "${path}" in :batch_insert() resolved to undefined`);
635
+ }
636
+ }
637
+ batchArgs.push({ path, isArray, values });
638
+ }
639
+ const arrayArgs = batchArgs.filter((b) => b.isArray);
640
+ if (arrayArgs.length === 0) {
641
+ throw new Error(':batch_insert() requires at least one array argument');
642
+ }
643
+ const rowCount = arrayArgs[0].values.length;
644
+ if (rowCount === 0) {
645
+ throw new Error(':batch_insert() array arguments must not be empty');
646
+ }
647
+ for (const argInfo of arrayArgs) {
648
+ if (argInfo.values.length !== rowCount) {
649
+ throw new Error(':batch_insert() arrays must share the same length');
650
+ }
651
+ }
652
+ // Build VALUES clause
653
+ const valueTuples = [];
654
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
655
+ const placeholders = [];
656
+ for (const argInfo of batchArgs) {
657
+ const value = argInfo.isArray ? argInfo.values[rowIndex] : argInfo.values[0];
658
+ placeholders.push(placeholder(pgIndex));
659
+ params.push(value);
660
+ }
661
+ valueTuples.push(`(${placeholders.join(', ')})`);
662
+ }
663
+ out.push('VALUES ', valueTuples.join(', '));
664
+ i = batchInsertEnd;
665
+ continue;
666
+ }
667
+ // Check for :filter(...) syntax (case-insensitive)
668
+ const filterMatch = /^:filter\s*\(/i.exec(sql.slice(i));
669
+ if (filterMatch) {
670
+ if (isArrayArgs) {
671
+ throw new Error(':filter() syntax only works with named parameters');
672
+ }
673
+ // Parse the condition list inside parentheses
674
+ let j = i + filterMatch[0].length; // Skip ':filter('
675
+ const conditionStart = j;
676
+ let parenDepth = 1;
677
+ let inString = false;
678
+ let stringChar = '';
679
+ // Find matching closing parenthesis
680
+ while (j < len && parenDepth > 0) {
681
+ const c = sql[j];
682
+ const cNext = j + 1 < len ? sql[j + 1] : '';
683
+ if (!inString) {
684
+ if (c === "'" || c === '"') {
685
+ inString = true;
686
+ stringChar = c;
687
+ }
688
+ else if (c === '(') {
689
+ parenDepth++;
690
+ }
691
+ else if (c === ')') {
692
+ parenDepth--;
693
+ }
694
+ }
695
+ else {
696
+ if (c === stringChar) {
697
+ // Check for escaped quote
698
+ if (cNext === stringChar) {
699
+ j++; // Skip escaped quote
700
+ }
701
+ else {
702
+ inString = false;
703
+ }
704
+ }
705
+ }
706
+ if (parenDepth > 0)
707
+ j++;
708
+ }
709
+ if (parenDepth > 0) {
710
+ throw new Error('Unclosed :filter() parentheses');
711
+ }
712
+ // Extract condition list (between the parentheses)
713
+ const conditionList = sql.slice(conditionStart, j).trim();
714
+ const filterEnd = j + 1; // Include closing ')'
715
+ // Parse condition list (split by comma, respecting quotes and parentheses)
716
+ const items = [];
717
+ let current = '';
718
+ let inSQ = false; // single quote string
719
+ let inDQ = false; // double quote identifier
720
+ let itemParenDepth = 0;
721
+ for (let k = 0; k < conditionList.length; k++) {
722
+ const ch = conditionList[k];
723
+ const next = k + 1 < conditionList.length ? conditionList[k + 1] : '';
724
+ if (inSQ) {
725
+ current += ch;
726
+ if (ch === "'" && next === "'") {
727
+ current += next;
728
+ k++;
729
+ }
730
+ else if (ch === "'") {
731
+ inSQ = false;
732
+ }
733
+ continue;
734
+ }
735
+ if (inDQ) {
736
+ current += ch;
737
+ if (ch === '"' && next === '"') {
738
+ current += next;
739
+ k++;
740
+ }
741
+ else if (ch === '"') {
742
+ inDQ = false;
743
+ }
744
+ continue;
745
+ }
746
+ if (ch === "'") {
747
+ current += ch;
748
+ inSQ = true;
749
+ continue;
750
+ }
751
+ if (ch === '"') {
752
+ current += ch;
753
+ inDQ = true;
754
+ continue;
755
+ }
756
+ if (ch === '(') {
757
+ current += ch;
758
+ itemParenDepth++;
759
+ continue;
760
+ }
761
+ if (ch === ')') {
762
+ current += ch;
763
+ itemParenDepth--;
764
+ continue;
765
+ }
766
+ if (ch === ',' && itemParenDepth === 0) {
767
+ const trimmed = current.trim();
768
+ if (trimmed.length > 0) {
769
+ items.push(trimmed);
770
+ }
771
+ current = '';
772
+ continue;
773
+ }
774
+ current += ch;
775
+ }
776
+ // Add the last item
777
+ const trimmed = current.trim();
778
+ if (trimmed.length > 0) {
779
+ items.push(trimmed);
780
+ }
781
+ if (items.length === 0) {
782
+ throw new Error(':filter() requires at least one condition');
783
+ }
784
+ // Build WHERE/HAVING conditions
785
+ const obj = args;
786
+ const conditions = [];
787
+ const filterParams = [];
788
+ let hasParameterized = false;
789
+ // Helper function to expand :or(:param) patterns
790
+ const expandOr = (expr) => {
791
+ // Find all :or(:param) patterns
792
+ const orPattern = /:or\s*\(\s*:(\w+)\s*\)/gi;
793
+ const matches = [];
794
+ let match;
795
+ while ((match = orPattern.exec(expr)) !== null) {
796
+ matches.push({
797
+ fullMatch: match[0],
798
+ paramName: match[1],
799
+ index: match.index
800
+ });
801
+ }
802
+ if (matches.length === 0) {
803
+ return { condition: '', params: [] };
804
+ }
805
+ // Check if all parameters are defined
806
+ let shouldInclude = true;
807
+ const replacements = [];
808
+ for (const m of matches) {
809
+ if (!Object.prototype.hasOwnProperty.call(obj, m.paramName) || obj[m.paramName] === undefined) {
810
+ shouldInclude = false;
811
+ break;
812
+ }
813
+ const arr = obj[m.paramName];
814
+ if (!Array.isArray(arr) || arr.length === 0) {
815
+ shouldInclude = false;
816
+ break;
817
+ }
818
+ // Find the column expression for this :or() pattern
819
+ // Look backwards from the match to find the column name and operator
820
+ const beforeOr = expr.slice(0, m.index);
821
+ const afterOr = expr.slice(m.index + m.fullMatch.length);
822
+ // Try to match column name and operator pattern
823
+ // Match patterns like "col =", "col >", "col <=", "col LIKE", etc.
824
+ const beforeTrimmed = beforeOr.trim();
825
+ const operatorMatch = beforeTrimmed.match(/(\w+)\s+([=<>!]+|>=|<=|!=|<>|LIKE|IN|BETWEEN|IS(?:\s+NOT)?)\s*$/i);
826
+ if (operatorMatch) {
827
+ const colName = operatorMatch[1];
828
+ const operator = operatorMatch[2];
829
+ const parts = [];
830
+ const params = [];
831
+ for (const val of arr) {
832
+ if (val === undefined)
833
+ continue;
834
+ parts.push(`${colName} ${operator} ${placeholder(pgIndex)}`);
835
+ params.push(val);
836
+ }
837
+ if (parts.length > 0) {
838
+ // Replace the column/operator and :or() together
839
+ // Find the operator text in the original expr by searching backwards
840
+ const operatorText = operatorMatch[0];
841
+ // Find the last occurrence of this pattern before m.index
842
+ let operatorStart = -1;
843
+ for (let pos = m.index - operatorText.length; pos >= 0; pos--) {
844
+ if (expr.slice(pos, pos + operatorText.length) === operatorText) {
845
+ operatorStart = pos;
846
+ break;
847
+ }
848
+ }
849
+ if (operatorStart === -1) {
850
+ // Fallback: use m.index - operatorText.length
851
+ operatorStart = Math.max(0, m.index - operatorText.length);
852
+ }
853
+ replacements.push({
854
+ index: operatorStart,
855
+ length: m.index + m.fullMatch.length - operatorStart,
856
+ replacement: parts.join(' OR '),
857
+ params
858
+ });
859
+ }
860
+ else {
861
+ shouldInclude = false;
862
+ break;
863
+ }
864
+ }
865
+ else {
866
+ // Fallback: use the entire before/after context
867
+ const parts = [];
868
+ const params = [];
869
+ for (const val of arr) {
870
+ if (val === undefined)
871
+ continue;
872
+ parts.push(`${beforeTrimmed}${placeholder(pgIndex)}${afterOr.trim()}`);
873
+ params.push(val);
874
+ }
875
+ if (parts.length > 0) {
876
+ replacements.push({
877
+ index: m.index,
878
+ length: m.fullMatch.length,
879
+ replacement: parts.join(' OR '),
880
+ params
881
+ });
882
+ }
883
+ else {
884
+ shouldInclude = false;
885
+ break;
886
+ }
887
+ }
888
+ }
889
+ if (!shouldInclude || replacements.length === 0) {
890
+ return { condition: '', params: [] };
891
+ }
892
+ // Replace from end to start to preserve indices
893
+ let resultExpr = expr;
894
+ const allParams = [];
895
+ for (const rep of replacements.reverse()) {
896
+ const before = resultExpr.slice(0, rep.index);
897
+ const after = resultExpr.slice(rep.index + rep.length);
898
+ resultExpr = before + rep.replacement + after;
899
+ allParams.unshift(...rep.params);
900
+ }
901
+ // Check if the original expression was already wrapped in parentheses
902
+ const trimmedExpr = expr.trim();
903
+ const alreadyWrapped = trimmedExpr.startsWith('(') && trimmedExpr.endsWith(')');
904
+ const wrappedCondition = alreadyWrapped ? resultExpr : `(${resultExpr})`;
905
+ return { condition: wrappedCondition, params: allParams };
906
+ };
907
+ for (const item of items) {
908
+ const trimmed = item.trim();
909
+ // Check for :or(:param) pattern first
910
+ const orResult = expandOr(trimmed);
911
+ if (orResult.condition) {
912
+ conditions.push(orResult.condition);
913
+ filterParams.push(...orResult.params);
914
+ continue;
915
+ }
916
+ // Check if this is a simple column name (no operators, no :param)
917
+ let hasOperator = false;
918
+ let hasParam = false;
919
+ let inString = false;
920
+ let stringChar = '';
921
+ for (let k = 0; k < trimmed.length; k++) {
922
+ const ch = trimmed[k];
923
+ const next = k + 1 < trimmed.length ? trimmed[k + 1] : '';
924
+ if (!inString && (ch === "'" || ch === '"')) {
925
+ inString = true;
926
+ stringChar = ch;
927
+ }
928
+ else if (inString) {
929
+ if (ch === stringChar) {
930
+ if (next === stringChar) {
931
+ k++; // Skip escaped quote
932
+ }
933
+ else {
934
+ inString = false;
935
+ }
936
+ }
937
+ }
938
+ else {
939
+ // Check for operators (outside strings)
940
+ if ((ch === '>' || ch === '<' || ch === '=' || ch === '!') ||
941
+ (ch === 'L' && trimmed.slice(k, k + 4).toUpperCase() === 'LIKE') ||
942
+ (ch === 'I' && trimmed.slice(k, k + 2).toUpperCase() === 'IN') ||
943
+ (ch === 'B' && trimmed.slice(k, k + 7).toUpperCase() === 'BETWEEN') ||
944
+ (ch === 'I' && trimmed.slice(k, k + 2).toUpperCase() === 'IS')) {
945
+ hasOperator = true;
946
+ }
947
+ // Check for :param pattern
948
+ if (ch === ':' && next && /[a-zA-Z_]/.test(next)) {
949
+ hasParam = true;
950
+ }
951
+ }
952
+ }
953
+ if (!hasOperator && !hasParam) {
954
+ // Simple column name → equality
955
+ hasParameterized = true;
956
+ if (Object.prototype.hasOwnProperty.call(obj, trimmed) && obj[trimmed] !== undefined) {
957
+ conditions.push(`${trimmed} = ${placeholder(pgIndex)}`);
958
+ filterParams.push(obj[trimmed]);
959
+ }
960
+ }
961
+ else if (hasParam) {
962
+ // Parameterized expression → extract and bind parameters
963
+ hasParameterized = true;
964
+ // Extract all :param patterns and check if they're defined
965
+ let shouldInclude = true;
966
+ const paramMatches = [];
967
+ let searchStart = 0;
968
+ while (true) {
969
+ const paramMatch = /:([a-zA-Z_][a-zA-Z0-9_]*)/g.exec(trimmed.slice(searchStart));
970
+ if (!paramMatch)
971
+ break;
972
+ const paramName = paramMatch[1];
973
+ const fullIndex = searchStart + paramMatch.index;
974
+ paramMatches.push({ name: paramName, index: fullIndex });
975
+ if (!Object.prototype.hasOwnProperty.call(obj, paramName) || obj[paramName] === undefined) {
976
+ shouldInclude = false;
977
+ break;
978
+ }
979
+ searchStart = fullIndex + paramMatch[0].length;
980
+ }
981
+ if (shouldInclude && paramMatches.length > 0) {
982
+ // Replace :param with placeholders (from end to start to preserve indices)
983
+ let conditionExpr = trimmed;
984
+ const orderedParams = [];
985
+ for (const pm of paramMatches.reverse()) {
986
+ const paramValue = obj[pm.name];
987
+ const before = conditionExpr.slice(0, pm.index);
988
+ const after = conditionExpr.slice(pm.index + pm.name.length + 1);
989
+ // Check if this is an array parameter (for IN clauses)
990
+ if (Array.isArray(paramValue)) {
991
+ if (paramValue.length === 0) {
992
+ shouldInclude = false;
993
+ break;
994
+ }
995
+ // Expand array: replace :ids with ?, ?, ?
996
+ const placeholders = paramValue.map(() => placeholder(pgIndex)).join(', ');
997
+ conditionExpr = before + placeholders + after;
998
+ orderedParams.unshift(...paramValue);
999
+ }
1000
+ else {
1001
+ conditionExpr = before + placeholder(pgIndex) + after;
1002
+ orderedParams.unshift(paramValue);
1003
+ }
1004
+ }
1005
+ if (shouldInclude) {
1006
+ // Add params in original order
1007
+ const finalParams = [];
1008
+ for (const pm of paramMatches.reverse()) {
1009
+ const paramValue = obj[pm.name];
1010
+ if (Array.isArray(paramValue)) {
1011
+ finalParams.push(...paramValue);
1012
+ }
1013
+ else {
1014
+ finalParams.push(paramValue);
1015
+ }
1016
+ }
1017
+ conditions.push(conditionExpr);
1018
+ filterParams.push(...finalParams);
1019
+ }
1020
+ }
1021
+ }
1022
+ else {
1023
+ // Literal expression → include as-is
1024
+ conditions.push(trimmed);
1025
+ }
1026
+ }
1027
+ // Only throw error if all parameterized conditions are undefined (literals are always included)
1028
+ if (hasParameterized && conditions.length === 0) {
1029
+ throw new Error('All conditions in :filter() are undefined');
1030
+ }
1031
+ if (conditions.length === 0) {
1032
+ throw new Error(':filter() requires at least one condition');
1033
+ }
1034
+ // Replace :filter(...) with conditions joined by AND
1035
+ out.push(conditions.join(' AND '));
1036
+ params.push(...filterParams);
1037
+ i = filterEnd;
1038
+ continue;
1039
+ }
279
1040
  // Attempt to parse a parameter token
280
1041
  let j = i + 1;
281
1042
  const start = j;