@minnowdb/core 0.6.9 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,14 @@ import { dateMilliseconds } from "../date-value.js";
2
2
  import { crossJoinPlan, isCrossJoinPlan } from "../plan/model.js";
3
3
  import { MAX_TEMP_RUN_BATCH_BYTES, MAX_TEMP_RUN_PAGE_BYTES, MAX_TEMP_RUN_PAGES_PER_BATCH, } from "../storage/types.js";
4
4
  import { throwIfAborted } from "./cancellation.js";
5
- import { cachedListMembership, childExpressions, distinctFromComparison, nullOrder, isScalarFunctionName, likeMatches, orderOutputName, parseQuantified, quantifiedComparison, scalarFunctionValue, unknownColumnDomains, } from "./query.js";
5
+ import { cachedListMembership, childExpressions, distinctFromComparison, nullOrder, isScalarFunctionName, likeMatches, orderOutputName, parseQuantified, quantifiedComparison, scalarFunctionEvaluator, scalarFunctionValue, unknownColumnDomains, } from "./query.js";
6
6
  import { jsonConstructor } from "./sql-json.js";
7
7
  import { bm25DocumentScore, cachedQueryTerms, FtsStatsAccumulator, fullTermsMask, renderDocumentValue, termFrequencies, termsMask, tokenize, } from "./fts.js";
8
8
  import { ByteGroupIndex } from "./group-index.js";
9
9
  import { ByteJoinIndex } from "./join-index.js";
10
10
  import { UnknownTableError } from "./errors.js";
11
11
  import { QueryMemoryBudgetError, QueryMemoryContext, } from "./memory.js";
12
- import { compareSqlValues, compileSimilarPattern, defineSqlResultProperty, } from "./sql-semantics.js";
12
+ import { coerceComparisonOperands, compareSqlValues, compileSimilarPattern, defineSqlResultProperty, } from "./sql-semantics.js";
13
13
  import { concatenatedSqlValue, exactNumericBinary, externalSqlDomainValue, isDateDomainValue, isExactNumeric, preservedJsonDomainValue, protectedSqlTextValue, } from "./sql-domains.js";
14
14
  import { buildSortKeyColumn, sortKeyIndexes } from "./sort-keys.js";
15
15
  const DEFAULT_BATCH_ROWS = 2_048;
@@ -97,6 +97,8 @@ async function writeSpillRowPages(store, ownerId, runId, startPageIndex, columns
97
97
  await writeSpillPages(store, pending, signal);
98
98
  return pageCount;
99
99
  }
100
+ /** Distinct raw values a packed number key column may carry before the generic index takes over. */
101
+ const NUMBER_KEY_CODE_CAP = 1 << 20;
100
102
  export function createColumnarTable(name, columns, uniqueKey) {
101
103
  const first = columns.values().next().value;
102
104
  const rowCount = first?.values.length ?? 0;
@@ -585,7 +587,10 @@ function bindPlan(plan, tables, memory, ftsStats) {
585
587
  if (primitive !== undefined)
586
588
  return { ...bound, primitive };
587
589
  const primitiveIn = detectPrimitiveInList(bound);
588
- return primitiveIn === undefined ? bound : { ...bound, primitiveIn };
590
+ if (primitiveIn !== undefined)
591
+ return { ...bound, primitiveIn };
592
+ const dictionaryPredicate = detectDictionaryPredicate(bound);
593
+ return dictionaryPredicate === undefined ? bound : { ...bound, dictionaryPredicate };
589
594
  };
590
595
  const predicates = plan.predicates.map((predicate) => {
591
596
  const bound = bindPredicate(predicate);
@@ -593,7 +598,8 @@ function bindPlan(plan, tables, memory, ftsStats) {
593
598
  return bound;
594
599
  if (bound.dictionaryEquality !== undefined ||
595
600
  bound.dictionaryLike !== undefined ||
596
- bound.dictionaryNumeric !== undefined) {
601
+ bound.dictionaryNumeric !== undefined ||
602
+ bound.dictionaryPredicate !== undefined) {
597
603
  return bound;
598
604
  }
599
605
  const branches = disjunctiveNormalForm(predicate);
@@ -663,7 +669,10 @@ function bindPlan(plan, tables, memory, ftsStats) {
663
669
  // vectors swap dictionaries per window; the accumulator remaps its slot table by value on
664
670
  // each swap, so windowed vectors qualify too.
665
671
  const groupColumn = groupBy.length === 1 ? groupBy[0] : undefined;
666
- const codeGrouping = grouped && groupColumn?.kind === "column" && groupColumn.vector.kind === "string"
672
+ const codeGrouping = !grouped || groupColumn === undefined ? undefined : codeGroupingFor(groupColumn);
673
+ const numberGrouping = grouped &&
674
+ groupColumn?.kind === "column" &&
675
+ (groupColumn.vector.kind === "number" || groupColumn.vector.kind === "datetime")
667
676
  ? { source: groupColumn.source, vector: groupColumn.vector }
668
677
  : undefined;
669
678
  return {
@@ -682,11 +691,82 @@ function bindPlan(plan, tables, memory, ftsStats) {
682
691
  grouped,
683
692
  sourceOrdered,
684
693
  ...(codeGrouping === undefined ? {} : { codeGrouping }),
694
+ ...(numberGrouping === undefined ? {} : { numberGrouping }),
685
695
  wildcard: plan.select[0]?.expression.kind === "wildcard",
686
696
  ...(plan.limit === undefined ? {} : { limit: plan.limit }),
687
697
  ...(plan.offset === undefined ? {} : { offset: plan.offset }),
688
698
  };
689
699
  }
700
+ /**
701
+ * A single GROUP BY key groups by dictionary code when it is a bare string column, or any
702
+ * expression whose only column is one string column: the value is then a function of the code.
703
+ */
704
+ function codeGroupingFor(key) {
705
+ if (key.kind === "column") {
706
+ return key.vector.kind === "string" ? { source: key.source, vector: key.vector } : undefined;
707
+ }
708
+ const column = singleStringColumn(key);
709
+ return column === undefined ? undefined : { ...column, expression: key };
710
+ }
711
+ /**
712
+ * A CASE the batch aggregate kernel can decide per dictionary code: every WHEN reads exactly one
713
+ * string column (the same one), and every THEN/ELSE is a bare number column or a numeric literal.
714
+ */
715
+ function conditionalAggregate(argument) {
716
+ if (argument.kind !== "case" || argument.branches.length === 0)
717
+ return undefined;
718
+ const whens = argument.branches.map((branch) => branch.when);
719
+ const first = whens[0];
720
+ if (first === undefined)
721
+ return undefined;
722
+ const conjunction = whens
723
+ .slice(1)
724
+ .reduce((left, right) => ({ kind: "logical", operator: "and", left, right, signature: "" }), first);
725
+ const column = singleStringColumn(conjunction);
726
+ if (column === undefined)
727
+ return undefined;
728
+ const branchValue = (expression) => {
729
+ if (expression === undefined)
730
+ return { kind: "literal", value: null };
731
+ if (expression.kind === "column" && expression.vector.kind === "number") {
732
+ return { kind: "column", source: expression.source, vector: expression.vector };
733
+ }
734
+ if (expression.kind === "literal" &&
735
+ (expression.value === null || typeof expression.value === "number")) {
736
+ return { kind: "literal", value: expression.value };
737
+ }
738
+ return undefined;
739
+ };
740
+ const branches = [];
741
+ for (const branch of argument.branches) {
742
+ const value = branchValue(branch.then);
743
+ if (value === undefined)
744
+ return undefined;
745
+ branches.push({ when: branch.when, value });
746
+ }
747
+ const otherwise = branchValue(argument.otherwise);
748
+ if (otherwise === undefined)
749
+ return undefined;
750
+ return {
751
+ ...column,
752
+ branches,
753
+ otherwise,
754
+ cache: { dictionary: undefined, branch: new Int32Array(0) },
755
+ };
756
+ }
757
+ /** The one string column an expression reads, when it reads exactly one column and no FTS. */
758
+ function singleStringColumn(expression) {
759
+ if (boundContainsFts(expression))
760
+ return undefined;
761
+ const columns = boundColumnNodes(expression);
762
+ const first = columns[0];
763
+ if (first?.vector.kind !== "string")
764
+ return undefined;
765
+ if (columns.some((column) => column.source !== first.source || column.column !== first.column)) {
766
+ return undefined;
767
+ }
768
+ return { source: first.source, vector: first.vector };
769
+ }
690
770
  function bindExpression(expression, sources, aggregateSpecs, aggregateIndexes, memory, ftsBySignature, ftsStats) {
691
771
  const signature = JSON.stringify(expression);
692
772
  if (expression.kind === "subquery") {
@@ -850,9 +930,14 @@ function bindExpression(expression, sources, aggregateSpecs, aggregateIndexes, m
850
930
  argument.vector.kind === "number"
851
931
  ? { source: argument.source, vector: argument.vector }
852
932
  : undefined;
933
+ const conditional = (expression.name === "COUNT" || expression.name === "SUM" || expression.name === "AVG") &&
934
+ expression.distinct !== true
935
+ ? conditionalAggregate(argument)
936
+ : undefined;
853
937
  aggregateSpecs.push({
854
938
  name: expression.name,
855
939
  argument,
940
+ ...(conditional === undefined ? {} : { conditional }),
856
941
  ...(expression.name === "STRING_AGG"
857
942
  ? { delimiter: required(arguments_[1], "STRING_AGG delimiter is missing") }
858
943
  : {}),
@@ -1225,7 +1310,10 @@ async function executeBoundPlanAsync(plan, memory, options) {
1225
1310
  const groups = new GroupAccumulator(plan, memory);
1226
1311
  const output = new ResultSink(plan, memory, options.loadScanWindow === undefined);
1227
1312
  const scanRows = plan.sourceTables[plan.scanSource]?.rowCount ?? 0;
1228
- for (let start = 0; start < scanRows;) {
1313
+ if (options.scanRows !== undefined && options.loadScanWindow !== undefined) {
1314
+ await scanSelectedRows(plan, options.scanRows, groups, output, memory, options);
1315
+ }
1316
+ for (let start = 0; start < scanRows && options.scanRows === undefined;) {
1229
1317
  throwIfAborted(options.signal);
1230
1318
  let length = Math.min(DEFAULT_BATCH_ROWS, scanRows - start);
1231
1319
  // The loader answers synchronously when the batch is already resident — the common case,
@@ -1272,6 +1360,49 @@ async function executeBoundPlanAsync(plan, memory, options) {
1272
1360
  const rows = plan.grouped ? finishGroups(plan, groups.values(), memory) : output.finish();
1273
1361
  return finishResult(plan, rows, memory);
1274
1362
  }
1363
+ /** Selected rows closer than this run in one batch; the predicates discard the rows between. */
1364
+ const SELECTED_ROW_COALESCE_GAP = 32;
1365
+ /**
1366
+ * Visits only the rows of an index-provided selection. Rows are ascending, so each streamed
1367
+ * window is loaded once; neighbouring selected rows inside a window run as one contiguous
1368
+ * batch, so a clustered hit costs one batch and a scattered one costs a batch per row, never
1369
+ * a batch per unselected row in between.
1370
+ */
1371
+ async function scanSelectedRows(plan, selection, groups, output, memory, options) {
1372
+ const scanRows = plan.sourceTables[plan.scanSource]?.rowCount ?? 0;
1373
+ let index = 0;
1374
+ while (index < selection.length) {
1375
+ throwIfAborted(options.signal);
1376
+ const first = selection[index] ?? 0;
1377
+ if (first >= scanRows)
1378
+ break;
1379
+ const loaded = options.loadScanWindow?.(first, 1);
1380
+ const residentEnd = typeof loaded === "number" || loaded === undefined ? loaded : await loaded;
1381
+ throwIfAborted(options.signal);
1382
+ const windowEnd = typeof residentEnd === "number" && residentEnd > first
1383
+ ? Math.min(residentEnd, scanRows)
1384
+ : first + 1;
1385
+ while (index < selection.length) {
1386
+ const begin = selection[index] ?? 0;
1387
+ if (begin >= windowEnd)
1388
+ break;
1389
+ let end = begin + 1;
1390
+ index += 1;
1391
+ while (index < selection.length) {
1392
+ const next = selection[index] ?? 0;
1393
+ if (next >= windowEnd ||
1394
+ next - end > SELECTED_ROW_COALESCE_GAP ||
1395
+ next >= begin + DEFAULT_BATCH_ROWS) {
1396
+ break;
1397
+ }
1398
+ end = next + 1;
1399
+ index += 1;
1400
+ }
1401
+ if (runScanBatch(plan, begin, end - begin, groups, output, memory))
1402
+ return;
1403
+ }
1404
+ }
1405
+ }
1275
1406
  /**
1276
1407
  * Pull-driven scan execution for cursor-safe plans. A result page owns a child memory context
1277
1408
  * that closes after the consumer settles, so both modeled memory and live row objects are
@@ -1307,6 +1438,7 @@ async function executeBoundPlanBatches(plan, memory, options, consume) {
1307
1438
  return values.length;
1308
1439
  },
1309
1440
  tryAddBatch: () => false,
1441
+ tryAddSelection: () => false,
1310
1442
  add: (batch, row) => {
1311
1443
  values.push(asQueryValue(evaluateBatchExpression(scanPlan, firstExpression, batch, row)));
1312
1444
  },
@@ -2391,8 +2523,91 @@ function applyPredicateKernel(plan, predicate, batch, selection, survivors) {
2391
2523
  if (predicate.disjunction !== undefined) {
2392
2524
  return filterDisjunction(plan, predicate.disjunction, batch, selection, survivors);
2393
2525
  }
2526
+ if (predicate.dictionaryPredicate !== undefined) {
2527
+ return filterDictionaryPredicate(plan, predicate, predicate.dictionaryPredicate, batch, selection, survivors);
2528
+ }
2394
2529
  return undefined;
2395
2530
  }
2531
+ /**
2532
+ * Filters by a predicate that depends on one dictionary column only. The truth for a code is
2533
+ * learned the first time a row carrying that code appears, by running the ordinary per-row
2534
+ * evaluator on that row — so LOWER(status) = 'paid', a CASE over region, or STARTS_WITH on a
2535
+ * category cost one generic evaluation per distinct value per dictionary, then one code read
2536
+ * per row. A NULL code is never a match, exactly as the generic path treats an unknown result.
2537
+ */
2538
+ function filterDictionaryPredicate(plan, predicate, fast, batch, selection, survivors) {
2539
+ const vector = fast.vector;
2540
+ if (fast.cache.dictionary !== vector.dictionary) {
2541
+ fast.cache.dictionary = vector.dictionary;
2542
+ fast.cache.truth = new Uint8Array(vector.dictionary.length + 1);
2543
+ }
2544
+ const truth = fast.cache.truth;
2545
+ const nullSlot = vector.dictionary.length;
2546
+ const rows = batch.rowsBySource[fast.source];
2547
+ const codes = vector.codes;
2548
+ const validity = vector.validity;
2549
+ const windowStart = vector.window?.start ?? 0;
2550
+ const slots = codes.length;
2551
+ const vectorLength = vector.length;
2552
+ let kept = 0;
2553
+ for (let index = 0; index < survivors; index += 1) {
2554
+ const row = selection[index] ?? 0;
2555
+ const sourceRow = rows?.[row] ?? -1;
2556
+ let code = nullSlot;
2557
+ if (sourceRow >= 0 && sourceRow < vectorLength) {
2558
+ const slot = sourceRow - windowStart;
2559
+ if (slot < 0 || slot >= slots) {
2560
+ throw new RangeError("Streamed vector row is outside the resident window");
2561
+ }
2562
+ if (((validity[slot >>> 3] ?? 0) & (1 << (slot & 7))) !== 0) {
2563
+ const raw = codes[slot] ?? NULL_STRING_CODE;
2564
+ if (raw !== NULL_STRING_CODE)
2565
+ code = raw;
2566
+ }
2567
+ }
2568
+ let known = truth[code] ?? 0;
2569
+ if (known === 0) {
2570
+ known = evaluateBatchPredicate(plan, predicate, batch, row) ? 1 : 2;
2571
+ truth[code] = known;
2572
+ }
2573
+ if (known !== 1)
2574
+ continue;
2575
+ selection[kept] = row;
2576
+ kept += 1;
2577
+ }
2578
+ return kept;
2579
+ }
2580
+ /** Slots one packed key column occupies: dictionary size plus NULL, or the number-code cap. */
2581
+ function multiCodeSpan(column) {
2582
+ return column.codes === undefined ? column.vector.dictionary.length + 1 : NUMBER_KEY_CODE_CAP;
2583
+ }
2584
+ /** Every column node under a bound expression. */
2585
+ function boundColumnNodes(expression, into = []) {
2586
+ if (expression.kind === "column")
2587
+ into.push(expression);
2588
+ else if (expression.kind !== "fts") {
2589
+ for (const child of boundChildren(expression))
2590
+ boundColumnNodes(child, into);
2591
+ }
2592
+ return into;
2593
+ }
2594
+ function boundContainsFts(expression) {
2595
+ if (expression.kind === "fts")
2596
+ return true;
2597
+ return boundChildren(expression).some(boundContainsFts);
2598
+ }
2599
+ function detectDictionaryPredicate(predicate) {
2600
+ const column = singleStringColumn({
2601
+ kind: "logical",
2602
+ operator: "and",
2603
+ left: predicate.left,
2604
+ right: predicate.right,
2605
+ signature: "",
2606
+ });
2607
+ return column === undefined
2608
+ ? undefined
2609
+ : { ...column, cache: { dictionary: undefined, truth: new Uint8Array(0) } };
2610
+ }
2396
2611
  // Scratch for the disjunction kernel. Branch predicates are always plain conditions, so a
2397
2612
  // branch never carries a disjunction of its own and these buffers are never reentered.
2398
2613
  let disjunctionCandidates = new Uint32Array(DEFAULT_BATCH_ROWS);
@@ -2938,6 +3153,12 @@ class ResultSink {
2938
3153
  * Returns false when the sink shape needs the per-row path.
2939
3154
  */
2940
3155
  tryAddBatch(batch) {
3156
+ return this.#tryAddRows(batch, undefined, batch.length);
3157
+ }
3158
+ tryAddSelection(batch, selection, survivors) {
3159
+ return this.#tryAddRows(batch, selection, survivors);
3160
+ }
3161
+ #tryAddRows(batch, selection, count) {
2941
3162
  const fast = this.#fastFirstKey;
2942
3163
  if (this.#capacity === undefined || fast === undefined)
2943
3164
  return false;
@@ -2950,7 +3171,19 @@ class ResultSink {
2950
3171
  const windowStart = window?.start ?? 0;
2951
3172
  const windowLength = window?.length ?? vector.length;
2952
3173
  const desc = fast.desc;
2953
- for (let row = 0; row < batch.length; row += 1) {
3174
+ // A key that arrives sorted against the requested direction `ORDER BY id DESC` over a
3175
+ // table stored in key order, the newest-first page — would otherwise improve on the cut
3176
+ // line on every row and rebuild the selection 200,000 times. Walking such a batch from
3177
+ // its last row establishes the cut line within `capacity` rows and rejects the rest on the
3178
+ // float comparison. Row order within a batch never affects the answer: ties keep the
3179
+ // earlier `seq`, and seq numbers are assigned in scan order below either way.
3180
+ const reversed = desc && this.#batchKeyIsAscending(rows, vector, selection, count);
3181
+ const firstSeq = this.#seq;
3182
+ for (let step = 0; step < count; step += 1) {
3183
+ const position = reversed ? count - 1 - step : step;
3184
+ const row = selection === undefined ? position : (selection[position] ?? 0);
3185
+ if (reversed)
3186
+ this.#seq = firstSeq + position;
2954
3187
  const threshold = this.#thresholdFirst;
2955
3188
  if (threshold === undefined || threshold === null) {
2956
3189
  this.#addCandidate(batch, row);
@@ -2969,6 +3202,31 @@ class ResultSink {
2969
3202
  }
2970
3203
  this.#addCandidate(batch, row);
2971
3204
  }
3205
+ if (reversed)
3206
+ this.#seq = firstSeq + count;
3207
+ return true;
3208
+ }
3209
+ /**
3210
+ * Whether the rows to add read the first order key in ascending order, checked directly on
3211
+ * the resident values: one unboxed pass, cheaper than a single candidate insertion.
3212
+ */
3213
+ #batchKeyIsAscending(rows, vector, selection, count) {
3214
+ if (rows === undefined || count < 2)
3215
+ return false;
3216
+ const windowStart = vector.window?.start ?? 0;
3217
+ const values = vector.values;
3218
+ const validity = vector.validity;
3219
+ let previous = Number.NEGATIVE_INFINITY;
3220
+ for (let position = 0; position < count; position += 1) {
3221
+ const row = selection === undefined ? position : (selection[position] ?? 0);
3222
+ const slot = (rows[row] ?? -1) - windowStart;
3223
+ if (slot < 0 || slot >= values.length || !isValid(validity, slot))
3224
+ return false;
3225
+ const value = values[slot] ?? 0;
3226
+ if (!(value >= previous))
3227
+ return false;
3228
+ previous = value;
3229
+ }
2972
3230
  return true;
2973
3231
  }
2974
3232
  add(batch, row) {
@@ -3198,7 +3456,13 @@ class GroupAccumulator {
3198
3456
  #codeColumns;
3199
3457
  #multiCodeColumns;
3200
3458
  #multiCodeStates;
3459
+ /** The dictionaries the packed slot table was laid out for; any swap re-lays it. */
3460
+ #multiCodeDictionaries = [];
3461
+ #multiCodeSlotsReserved = 0;
3201
3462
  #multiCodeScratch = [];
3463
+ /** Raw-number grouping: state per finite value, plus the NULL group. */
3464
+ #numberStates;
3465
+ #numberNullState;
3202
3466
  #keyScratch = [];
3203
3467
  // The miss factories live on the accumulator and read the pending row through these fields, so
3204
3468
  // the per-row lookup never allocates a capturing closure; execution is synchronous, so the
@@ -3229,42 +3493,93 @@ class GroupAccumulator {
3229
3493
  // are stable and value-unique within one execution, so the key encodes a fixed-width number
3230
3494
  // instead of re-encoding the string's UTF-8 on every row. Types are stable per position, so
3231
3495
  // a code can never collide with a genuine number from the same expression.
3232
- this.#codeColumns =
3233
- plan.groupBy.length > 1
3234
- ? plan.groupBy.map((expression) => expression.kind === "column" &&
3235
- expression.vector.kind === "string" &&
3236
- expression.vector.window === undefined
3237
- ? { source: expression.source, vector: expression.vector }
3238
- : undefined)
3239
- : [];
3240
- // When every compound key column is dictionary-coded and the combined code space is small,
3241
- // group lookup packs codes into one exact integer. Small domains use a direct array; large,
3242
- // sparse domains use a numeric Map instead of byte-encoding and hashing each compound key.
3243
- // Each column contributes (dictionary size + 1) slots, the extra one for NULL.
3244
- if (this.#codeColumns.length > 1 && this.#codeColumns.every((column) => column !== undefined)) {
3245
- const columns = this.#codeColumns;
3246
- let slots = 1;
3247
- for (const column of columns)
3248
- slots *= column.vector.dictionary.length + 1;
3249
- if (Number.isSafeInteger(slots) && slots <= MULTI_CODE_GROUP_SLOT_CAP) {
3496
+ const codeColumns = plan.groupBy.length > 1
3497
+ ? plan.groupBy.map((expression) => expression.kind === "column" && expression.vector.kind === "string"
3498
+ ? { source: expression.source, vector: expression.vector }
3499
+ : undefined)
3500
+ : [];
3501
+ // When every compound key column is a bare string, number, or datetime column, group lookup
3502
+ // packs one code per column into one integer slot and byte-encodes a key only on the first
3503
+ // row of each combination per window. The slot table is laid out lazily by
3504
+ // #ensureMultiCodeStates, which re-lays it whenever a streamed window swaps a dictionary;
3505
+ // the group states themselves live in the value-keyed index, so a combination seen in two
3506
+ // windows is one group.
3507
+ const packed = plan.groupBy.length > 1
3508
+ ? plan.groupBy.map((expression) => {
3509
+ if (expression.kind !== "column")
3510
+ return undefined;
3511
+ if (expression.vector.kind === "string") {
3512
+ return { source: expression.source, vector: expression.vector };
3513
+ }
3514
+ if (expression.vector.kind === "number" || expression.vector.kind === "datetime") {
3515
+ return {
3516
+ source: expression.source,
3517
+ vector: expression.vector,
3518
+ codes: new Map(),
3519
+ };
3520
+ }
3521
+ return undefined;
3522
+ })
3523
+ : [];
3524
+ if (packed.length > 1 && packed.every((column) => column !== undefined)) {
3525
+ this.#multiCodeColumns = packed;
3526
+ this.#codeColumns = [];
3527
+ return;
3528
+ }
3529
+ // Otherwise the compound key substitutes the dictionary code for each bare unwindowed string
3530
+ // column: codes are stable and value-unique within one execution, so the key encodes a
3531
+ // fixed-width number instead of re-encoding the string's UTF-8 on every row. Types are
3532
+ // stable per position, so a code can never collide with a genuine number from the same
3533
+ // expression.
3534
+ this.#codeColumns = codeColumns.map((column) => column?.vector.window === undefined ? column : undefined);
3535
+ if (plan.numberGrouping !== undefined)
3536
+ this.#numberStates = new Map();
3537
+ }
3538
+ /**
3539
+ * Lays out the packed slot table for the current window dictionaries. Small combined domains
3540
+ * use a direct array (reserved against the budget); large ones a numeric Map. Returns false
3541
+ * when the budget refuses the array, which sends the query to the generic per-row index.
3542
+ */
3543
+ #ensureMultiCodeStates(columns) {
3544
+ let current = true;
3545
+ for (let index = 0; index < columns.length; index += 1) {
3546
+ const column = columns[index];
3547
+ if (column?.codes !== undefined)
3548
+ continue;
3549
+ if (this.#multiCodeDictionaries[index] !== column?.vector.dictionary) {
3550
+ current = false;
3551
+ break;
3552
+ }
3553
+ }
3554
+ if (current && (this.#codeStates !== undefined || this.#multiCodeStates !== undefined)) {
3555
+ return true;
3556
+ }
3557
+ let slots = 1;
3558
+ for (const column of columns)
3559
+ slots *= multiCodeSpan(column);
3560
+ if (!Number.isSafeInteger(slots))
3561
+ return false;
3562
+ if (slots <= MULTI_CODE_GROUP_SLOT_CAP) {
3563
+ if (slots > this.#multiCodeSlotsReserved) {
3250
3564
  try {
3251
- memory.reserve(safeMemoryProduct(slots, QUERY_REFERENCE_BYTES, "Group code slots"), "Group code slots");
3565
+ this.#memory.reserve(safeMemoryProduct(slots - this.#multiCodeSlotsReserved, QUERY_REFERENCE_BYTES, "Group code slots"), "Group code slots");
3252
3566
  }
3253
3567
  catch (error) {
3254
3568
  if (!(error instanceof QueryMemoryBudgetError))
3255
3569
  throw error;
3256
- return;
3570
+ return false;
3257
3571
  }
3258
- this.#multiCodeColumns = columns;
3259
- this.#codeStates = new Array(slots).fill(undefined);
3260
- this.#ordered = [];
3261
- }
3262
- else if (Number.isSafeInteger(slots)) {
3263
- this.#multiCodeColumns = columns;
3264
- this.#multiCodeStates = new Map();
3265
- this.#ordered = [];
3572
+ this.#multiCodeSlotsReserved = slots;
3266
3573
  }
3574
+ this.#codeStates = new Array(slots).fill(undefined);
3575
+ this.#multiCodeStates = undefined;
3267
3576
  }
3577
+ else {
3578
+ this.#codeStates = undefined;
3579
+ this.#multiCodeStates = new Map();
3580
+ }
3581
+ this.#multiCodeDictionaries = columns.map((column) => column.codes === undefined ? column.vector.dictionary : undefined);
3582
+ return true;
3268
3583
  }
3269
3584
  /**
3270
3585
  * Fast aggregate specs for the batch kernel: every aggregate is COUNT(*) or a bare numeric
@@ -3286,6 +3601,10 @@ class GroupAccumulator {
3286
3601
  specs.push({ kind: "column", sums: spec.name !== "COUNT" });
3287
3602
  continue;
3288
3603
  }
3604
+ if (spec.conditional !== undefined) {
3605
+ specs.push({ kind: "conditional", sums: spec.name !== "COUNT" });
3606
+ continue;
3607
+ }
3289
3608
  return undefined;
3290
3609
  }
3291
3610
  return specs;
@@ -3299,7 +3618,10 @@ class GroupAccumulator {
3299
3618
  const plan = this.#plan;
3300
3619
  const codeGrouping = plan.codeGrouping;
3301
3620
  const globalGroup = plan.groupBy.length === 0;
3302
- if (!globalGroup && (codeGrouping === undefined || this.#codeStates === undefined)) {
3621
+ const numberGrouped = plan.numberGrouping !== undefined && this.#numberStates !== undefined;
3622
+ if (!globalGroup &&
3623
+ !numberGrouped &&
3624
+ (codeGrouping === undefined || this.#codeStates === undefined)) {
3303
3625
  return false;
3304
3626
  }
3305
3627
  const specs = this.#fastAggregatesCache;
@@ -3324,6 +3646,10 @@ class GroupAccumulator {
3324
3646
  : undefined;
3325
3647
  // Hoisted per-column reads: the row loop touches only local typed arrays and numbers.
3326
3648
  const columns = [];
3649
+ // Conditional aggregates: the branch for a dictionary code is learned from the first row
3650
+ // carrying it (the generic truth evaluator on that row), then every later row with the code
3651
+ // reads its branch's number column or literal directly.
3652
+ const conditionals = [];
3327
3653
  let stars = 0;
3328
3654
  for (let index = 0; index < specs.length; index += 1) {
3329
3655
  const spec = specs[index];
@@ -3331,6 +3657,26 @@ class GroupAccumulator {
3331
3657
  stars += 1;
3332
3658
  continue;
3333
3659
  }
3660
+ if (spec.kind === "conditional") {
3661
+ const conditional = plan.aggregates[index]?.conditional;
3662
+ if (conditional === undefined)
3663
+ return false;
3664
+ const vector = conditional.vector;
3665
+ if (conditional.cache.dictionary !== vector.dictionary) {
3666
+ conditional.cache.dictionary = vector.dictionary;
3667
+ conditional.cache.branch = new Int32Array(vector.dictionary.length + 1).fill(-2);
3668
+ }
3669
+ conditionals.push({
3670
+ index,
3671
+ sums: spec.sums,
3672
+ spec: conditional,
3673
+ rows: batch.rowsBySource[conditional.source],
3674
+ branch: conditional.cache.branch,
3675
+ nullSlot: vector.dictionary.length,
3676
+ truths: conditional.branches.map((branch) => compiledTruth(plan, branch.when)),
3677
+ });
3678
+ continue;
3679
+ }
3334
3680
  const raw = plan.aggregates[index]?.rawNumber;
3335
3681
  if (raw === undefined)
3336
3682
  return false;
@@ -3371,14 +3717,10 @@ class GroupAccumulator {
3371
3717
  code = rawCode;
3372
3718
  }
3373
3719
  }
3374
- state = states[code];
3375
- if (state === undefined) {
3376
- const value = code === nullCode ? null : (grouping.dictionary[code] ?? null);
3377
- state = createGroupState([value], plan, this.#memory);
3378
- states[code] = state;
3379
- this.#registerCodeState(value, state);
3380
- this.#ordered?.push(state);
3381
- }
3720
+ state = states[code] ?? this.#createCodeState(batch, row, code, grouping.dictionary);
3721
+ }
3722
+ else if (state === undefined && numberGrouped) {
3723
+ state = this.stateFor(batch, row);
3382
3724
  }
3383
3725
  if (state === undefined)
3384
3726
  return false;
@@ -3404,6 +3746,36 @@ class GroupAccumulator {
3404
3746
  state.sums[column.index] = (state.sums[column.index] ?? 0) + (column.values[slot] ?? 0);
3405
3747
  }
3406
3748
  }
3749
+ for (const conditional of conditionals) {
3750
+ const code = stringCodeAt(conditional.spec.vector, conditional.rows?.[row] ?? -1) ??
3751
+ conditional.nullSlot;
3752
+ let chosen = conditional.branch[code] ?? -2;
3753
+ if (chosen === -2) {
3754
+ chosen = -1;
3755
+ for (let index = 0; index < conditional.truths.length; index += 1) {
3756
+ if (conditional.truths[index]?.(batch, row) === true) {
3757
+ chosen = index;
3758
+ break;
3759
+ }
3760
+ }
3761
+ conditional.branch[code] = chosen;
3762
+ }
3763
+ const branchValue = chosen < 0 ? conditional.spec.otherwise : conditional.spec.branches[chosen]?.value;
3764
+ if (branchValue === undefined)
3765
+ return false;
3766
+ let value;
3767
+ if (branchValue.kind === "literal")
3768
+ value = branchValue.value;
3769
+ else {
3770
+ value = rawFloat64Value(branchValue.vector, batch.rowsBySource[branchValue.source]?.[row] ?? -1);
3771
+ }
3772
+ if (value === null)
3773
+ continue;
3774
+ counts[conditional.index] = (counts[conditional.index] ?? 0) + 1;
3775
+ if (conditional.sums) {
3776
+ state.sums[conditional.index] = (state.sums[conditional.index] ?? 0) + value;
3777
+ }
3778
+ }
3407
3779
  }
3408
3780
  return true;
3409
3781
  }
@@ -3425,12 +3797,14 @@ class GroupAccumulator {
3425
3797
  this.#codeSlotsReserved = slots;
3426
3798
  }
3427
3799
  const next = new Array(slots).fill(undefined);
3428
- for (let code = 0; code < dictionary.length; code += 1) {
3429
- const state = this.#codeStateByValue.get(dictionary[code] ?? "");
3430
- if (state !== undefined)
3431
- next[code] = state;
3800
+ if (this.#plan.codeGrouping?.expression === undefined) {
3801
+ for (let code = 0; code < dictionary.length; code += 1) {
3802
+ const state = this.#codeStateByValue.get(dictionary[code] ?? "");
3803
+ if (state !== undefined)
3804
+ next[code] = state;
3805
+ }
3806
+ next[dictionary.length] = this.#nullCodeState;
3432
3807
  }
3433
- next[dictionary.length] = this.#nullCodeState;
3434
3808
  this.#codeStates = next;
3435
3809
  this.#codeDictionary = dictionary;
3436
3810
  }
@@ -3441,6 +3815,33 @@ class GroupAccumulator {
3441
3815
  else
3442
3816
  this.#codeStateByValue.set(value, state);
3443
3817
  }
3818
+ /**
3819
+ * Fills the slot for a dictionary code seen for the first time. A bare column's slot is a new
3820
+ * state keyed by the dictionary value; an expression key evaluates the expression on this row
3821
+ * (the representative of its code) and shares the state of any earlier code that produced the
3822
+ * same value, so UPPER('a') and UPPER('A') land in one group.
3823
+ */
3824
+ #createCodeState(batch, row, code, dictionary) {
3825
+ const states = required(this.#codeStates, "Group code slots are missing");
3826
+ const expression = this.#plan.codeGrouping?.expression;
3827
+ let state;
3828
+ if (expression === undefined) {
3829
+ const value = code === dictionary.length ? null : (dictionary[code] ?? null);
3830
+ state = createGroupState([value], this.#plan, this.#memory);
3831
+ this.#registerCodeState(value, state);
3832
+ this.#ordered?.push(state);
3833
+ }
3834
+ else {
3835
+ const value = asQueryValue(evaluateBatchExpression(this.#plan, expression, batch, row));
3836
+ this.#pendingSingleValue = value;
3837
+ const before = this.#index.size;
3838
+ state = this.#index.getOrInsertOne(groupKey(value), this.#createPendingSingle);
3839
+ if (this.#index.size !== before)
3840
+ this.#ordered?.push(state);
3841
+ }
3842
+ states[code] = state;
3843
+ return state;
3844
+ }
3444
3845
  /** Resolves the group state for one row, creating it on first touch. */
3445
3846
  stateFor(batch, row) {
3446
3847
  const plan = this.#plan;
@@ -3463,55 +3864,115 @@ class GroupAccumulator {
3463
3864
  code = rawCode;
3464
3865
  }
3465
3866
  }
3466
- let state = states[code];
3467
- if (state === undefined) {
3468
- const value = code === vector.dictionary.length ? null : (vector.dictionary[code] ?? null);
3469
- state = createGroupState([value], plan, this.#memory);
3470
- states[code] = state;
3471
- this.#registerCodeState(value, state);
3472
- this.#ordered?.push(state);
3473
- }
3474
- return state;
3867
+ return states[code] ?? this.#createCodeState(batch, row, code, vector.dictionary);
3475
3868
  }
3476
3869
  if (plan.groupBy.length === 0) {
3477
3870
  return required(this.#index.getEmpty(), "Grouped query state is missing");
3478
3871
  }
3479
3872
  const multiCode = this.#multiCodeColumns;
3480
- if (multiCode !== undefined &&
3481
- (this.#codeStates !== undefined || this.#multiCodeStates !== undefined)) {
3873
+ if (multiCode !== undefined) {
3874
+ if (!this.#ensureMultiCodeStates(multiCode)) {
3875
+ this.#multiCodeColumns = undefined;
3876
+ this.#codeStates = undefined;
3877
+ this.#multiCodeStates = undefined;
3878
+ return this.stateFor(batch, row);
3879
+ }
3482
3880
  let slot = 0;
3483
3881
  for (let index = 0; index < multiCode.length; index += 1) {
3484
3882
  const column = required(multiCode[index], "Group code column is missing");
3485
- const vector = column.vector;
3486
3883
  const sourceRow = batch.rowsBySource[column.source]?.[row] ?? -1;
3487
- let code = vector.dictionary.length;
3488
- if (sourceRow >= 0 && sourceRow < vector.length && isValid(vector.validity, sourceRow)) {
3489
- const rawCode = vector.codes[sourceRow] ?? NULL_STRING_CODE;
3490
- if (rawCode !== NULL_STRING_CODE)
3491
- code = rawCode;
3884
+ let code;
3885
+ if (column.codes === undefined) {
3886
+ code = stringCodeAt(column.vector, sourceRow) ?? column.vector.dictionary.length;
3887
+ }
3888
+ else {
3889
+ // Code 0 is NULL and every other finite value gets the next dense code; a key column
3890
+ // with too many distinct values, or a non-finite one, hands the query to the index.
3891
+ const raw = rawFloat64Value(column.vector, sourceRow);
3892
+ if (raw === null)
3893
+ code = 0;
3894
+ else if (!Number.isFinite(raw))
3895
+ code = -1;
3896
+ else {
3897
+ const known = column.codes.get(raw);
3898
+ if (known !== undefined)
3899
+ code = known;
3900
+ else if (column.codes.size + 1 >= NUMBER_KEY_CODE_CAP)
3901
+ code = -1;
3902
+ else {
3903
+ code = column.codes.size + 1;
3904
+ this.#memory.tally(PACKED_GROUP_ENTRY_BYTES, "Packed group key code");
3905
+ column.codes.set(raw, code);
3906
+ }
3907
+ }
3908
+ if (code < 0) {
3909
+ this.#multiCodeColumns = undefined;
3910
+ this.#codeStates = undefined;
3911
+ this.#multiCodeStates = undefined;
3912
+ return this.stateFor(batch, row);
3913
+ }
3492
3914
  }
3493
3915
  this.#multiCodeScratch[index] = code;
3494
- slot = slot * (vector.dictionary.length + 1) + code;
3916
+ slot = slot * multiCodeSpan(column) + code;
3495
3917
  }
3496
3918
  let state = this.#codeStates?.[slot] ?? this.#multiCodeStates?.get(slot);
3497
3919
  if (state === undefined) {
3498
- const groupValues = [];
3499
3920
  for (let index = 0; index < multiCode.length; index += 1) {
3500
- const vector = required(multiCode[index], "Group code column is missing").vector;
3501
- const code = this.#multiCodeScratch[index] ?? vector.dictionary.length;
3502
- groupValues.push(code === vector.dictionary.length ? null : (vector.dictionary[code] ?? null));
3921
+ const column = required(multiCode[index], "Group code column is missing");
3922
+ const code = this.#multiCodeScratch[index] ?? 0;
3923
+ if (column.codes === undefined) {
3924
+ const vector = column.vector;
3925
+ this.#keyScratch[index] =
3926
+ code === vector.dictionary.length ? null : (vector.dictionary[code] ?? null);
3927
+ }
3928
+ else {
3929
+ const raw = rawFloat64Value(column.vector, batch.rowsBySource[column.source]?.[row] ?? -1);
3930
+ this.#keyScratch[index] =
3931
+ raw === null
3932
+ ? null
3933
+ : groupKey(column.vector.kind === "datetime" ? new Date(raw) : raw);
3934
+ }
3503
3935
  }
3504
- state = createGroupState(groupValues, plan, this.#memory);
3936
+ this.#keyScratch.length = multiCode.length;
3937
+ this.#pendingBatch = batch;
3938
+ this.#pendingRow = row;
3939
+ state = this.#index.getOrInsert(this.#keyScratch, this.#createPendingCompound);
3505
3940
  if (this.#codeStates !== undefined)
3506
3941
  this.#codeStates[slot] = state;
3507
3942
  else {
3508
3943
  this.#memory.tally(PACKED_GROUP_ENTRY_BYTES, "Packed group index entry");
3509
3944
  this.#multiCodeStates?.set(slot, state);
3510
3945
  }
3511
- this.#ordered?.push(state);
3512
3946
  }
3513
3947
  return state;
3514
3948
  }
3949
+ const numberGrouping = plan.numberGrouping;
3950
+ const numberStates = this.#numberStates;
3951
+ if (numberGrouping !== undefined && numberStates !== undefined) {
3952
+ const raw = rawFloat64Value(numberGrouping.vector, batch.rowsBySource[numberGrouping.source]?.[row] ?? -1);
3953
+ if (raw === null) {
3954
+ let state = this.#numberNullState;
3955
+ if (state === undefined) {
3956
+ this.#pendingSingleValue = null;
3957
+ state = this.#index.getOrInsertOne(null, this.#createPendingSingle);
3958
+ this.#numberNullState = state;
3959
+ }
3960
+ return state;
3961
+ }
3962
+ // Non-finite values keep the generic key rules (they share the NULL key), so only finite
3963
+ // values are cached by raw value.
3964
+ if (Number.isFinite(raw)) {
3965
+ let state = numberStates.get(raw);
3966
+ if (state === undefined) {
3967
+ const value = numberGrouping.vector.kind === "datetime" ? new Date(raw) : raw;
3968
+ this.#pendingSingleValue = value;
3969
+ state = this.#index.getOrInsertOne(groupKey(value), this.#createPendingSingle);
3970
+ this.#memory.tally(PACKED_GROUP_ENTRY_BYTES, "Packed group index entry");
3971
+ numberStates.set(raw, state);
3972
+ }
3973
+ return state;
3974
+ }
3975
+ }
3515
3976
  if (plan.groupBy.length === 1) {
3516
3977
  const groupValue = asQueryValue(evaluateBatchExpression(plan, required(plan.groupBy[0], "Group expression is missing"), batch, row));
3517
3978
  this.#pendingSingleValue = groupValue;
@@ -3567,6 +4028,8 @@ function consumeBatch(plan, batch, groups, output, memory, prefiltered = false)
3567
4028
  }
3568
4029
  return;
3569
4030
  }
4031
+ if (output.tryAddSelection(batch, selection, survivors))
4032
+ return;
3570
4033
  for (let index = 0; index < survivors; index += 1) {
3571
4034
  output.add(batch, selection[index] ?? 0);
3572
4035
  if (reachedEarlyLimit(plan, output.size))
@@ -3680,7 +4143,21 @@ function updateAggregates(plan, state, batch, row, memory) {
3680
4143
  // the single surviving value.
3681
4144
  if (spec.rawDatetime !== undefined) {
3682
4145
  const sourceRow = batch.rowsBySource[spec.rawDatetime.source]?.[row] ?? -1;
3683
- applyAggregateValue(spec, state, index, rawFloat64Value(spec.rawDatetime.vector, sourceRow), memory);
4146
+ const value = rawFloat64Value(spec.rawDatetime.vector, sourceRow);
4147
+ if (value !== null &&
4148
+ spec.distinct !== true &&
4149
+ (spec.name === "MIN" || spec.name === "MAX")) {
4150
+ // Epoch milliseconds compare as plain numbers; the generic comparator would classify
4151
+ // both operands on every row of the scan.
4152
+ state.counts[index] = (state.counts[index] ?? 0) + 1;
4153
+ const current = state.values[index];
4154
+ if (typeof current !== "number" ||
4155
+ (spec.name === "MIN" ? value < current : value > current)) {
4156
+ replaceAggregateValue(state, index, value, `${spec.name} aggregate value`, memory);
4157
+ }
4158
+ continue;
4159
+ }
4160
+ applyAggregateValue(spec, state, index, value, memory);
3684
4161
  continue;
3685
4162
  }
3686
4163
  // A bare number column reads its Float64Array slot directly and accumulates SUM/AVG into
@@ -3697,16 +4174,21 @@ function updateAggregates(plan, state, batch, row, memory) {
3697
4174
  if (spec.name === "SUM" || spec.name === "AVG") {
3698
4175
  state.sums[index] = (state.sums[index] ?? 0) + value;
3699
4176
  }
3700
- else if (spec.name === "MIN") {
4177
+ else if (spec.name === "MIN" || spec.name === "MAX") {
4178
+ // A raw number compares as a number. NaN keeps compareValues's place, after every
4179
+ // finite value: it is never the minimum and always the maximum once seen.
3701
4180
  const current = state.values[index];
3702
- if (current === undefined || compareValues(value, current) < 0) {
3703
- replaceAggregateValue(state, index, value, "MIN aggregate value", memory);
3704
- }
3705
- }
3706
- else if (spec.name === "MAX") {
3707
- const current = state.values[index];
3708
- if (current === undefined || compareValues(value, current) > 0) {
3709
- replaceAggregateValue(state, index, value, "MAX aggregate value", memory);
4181
+ const better = typeof current !== "number"
4182
+ ? true
4183
+ : Number.isNaN(value)
4184
+ ? spec.name === "MAX" && !Number.isNaN(current)
4185
+ : Number.isNaN(current)
4186
+ ? spec.name === "MIN"
4187
+ : spec.name === "MIN"
4188
+ ? value < current
4189
+ : value > current;
4190
+ if (better) {
4191
+ replaceAggregateValue(state, index, value, `${spec.name} aggregate value`, memory);
3710
4192
  }
3711
4193
  }
3712
4194
  }
@@ -4648,50 +5130,134 @@ function inListHolds(operator, value, items) {
4648
5130
  }
4649
5131
  return operator === "NOT IN" && !hasNull;
4650
5132
  }
5133
+ const compiledBatchExpressions = new WeakMap();
5134
+ /**
5135
+ * Compiles a bound expression to a closure once per plan, so a scan does not re-dispatch on the
5136
+ * node kind, re-check the function name, or allocate an argument array on every row. The
5137
+ * closures call the same value functions the interpreter does — binaryValue,
5138
+ * scalarFunctionValue, booleanTruth — so semantics are shared, only the walk is hoisted.
5139
+ */
5140
+ function compiledBatchExpression(plan, expression) {
5141
+ const cached = compiledBatchExpressions.get(expression);
5142
+ if (cached !== undefined)
5143
+ return cached;
5144
+ let compiled;
5145
+ switch (expression.kind) {
5146
+ case "literal": {
5147
+ const value = expression.value;
5148
+ compiled = () => value;
5149
+ break;
5150
+ }
5151
+ case "wildcard":
5152
+ compiled = () => 1;
5153
+ break;
5154
+ case "column": {
5155
+ const vector = expression.vector;
5156
+ const source = expression.source;
5157
+ compiled = (batch, row) => vectorValue(vector, batch.rowsBySource[source]?.[row] ?? -1);
5158
+ break;
5159
+ }
5160
+ case "binary": {
5161
+ const operator = expression.operator;
5162
+ const left = compiledBatchExpression(plan, expression.left);
5163
+ const right = compiledBatchExpression(plan, expression.right);
5164
+ compiled = (batch, row) => binaryValue(operator, left(batch, row), right(batch, row));
5165
+ break;
5166
+ }
5167
+ case "case": {
5168
+ const branches = expression.branches.map((branch) => ({
5169
+ when: compiledTruth(plan, branch.when),
5170
+ then: compiledBatchExpression(plan, branch.then),
5171
+ }));
5172
+ const otherwise = expression.otherwise === undefined
5173
+ ? undefined
5174
+ : compiledBatchExpression(plan, expression.otherwise);
5175
+ compiled = (batch, row) => {
5176
+ for (const branch of branches) {
5177
+ if (branch.when(batch, row) === true)
5178
+ return branch.then(batch, row);
5179
+ }
5180
+ return otherwise === undefined ? null : otherwise(batch, row);
5181
+ };
5182
+ break;
5183
+ }
5184
+ case "condition":
5185
+ case "logical":
5186
+ case "not": {
5187
+ const truth = compiledTruth(plan, expression);
5188
+ compiled = truth;
5189
+ break;
5190
+ }
5191
+ case "fts":
5192
+ compiled = (batch, row) => expression.op === "match"
5193
+ ? ftsBatchTruth(expression, batch, null, row)
5194
+ : ftsBm25BatchValue(expression, batch, null, row);
5195
+ break;
5196
+ case "list":
5197
+ compiled = () => {
5198
+ throw new TypeError("Value lists are only supported with IN");
5199
+ };
5200
+ break;
5201
+ default: {
5202
+ if (expression.name === "COALESCE") {
5203
+ const parts = expression.arguments.map((argument) => compiledBatchExpression(plan, argument));
5204
+ compiled = (batch, row) => {
5205
+ for (const part of parts) {
5206
+ const candidate = part(batch, row);
5207
+ if (candidate !== null && candidate !== undefined)
5208
+ return candidate;
5209
+ }
5210
+ return null;
5211
+ };
5212
+ break;
5213
+ }
5214
+ const name = expression.name;
5215
+ if (!isScalarFunctionName(name)) {
5216
+ compiled = () => {
5217
+ throw new TypeError(`${name} requires grouped execution`);
5218
+ };
5219
+ break;
5220
+ }
5221
+ const parts = expression.arguments.map((argument) => compiledBatchExpression(plan, argument));
5222
+ // One argument array per call site, refilled per row: a function never re-enters its own
5223
+ // evaluation, so the slots are free again before the next row reads them.
5224
+ const values = new Array(parts.length);
5225
+ const evaluate = scalarFunctionEvaluator(name);
5226
+ compiled = (batch, row) => {
5227
+ let index = 0;
5228
+ for (const part of parts) {
5229
+ values[index] = part(batch, row);
5230
+ index += 1;
5231
+ }
5232
+ return evaluate(values);
5233
+ };
5234
+ }
5235
+ }
5236
+ compiledBatchExpressions.set(expression, compiled);
5237
+ return compiled;
5238
+ }
5239
+ /** A boolean node compiled over its children, keeping booleanTruth's three-valued rules. */
5240
+ function compiledTruth(plan, expression) {
5241
+ // booleanTruth reads operands through a callback; binding the current row into one closure
5242
+ // per node avoids allocating that callback per row. A node is never re-entered while its
5243
+ // own operands evaluate, so the shared slots are safe.
5244
+ const state = { batch: undefined, row: 0 };
5245
+ const evaluateValue = (nested) => compiledBatchExpression(plan, nested)(state.batch, state.row);
5246
+ return (batch, row) => {
5247
+ state.batch = batch;
5248
+ state.row = row;
5249
+ return booleanTruth(expression, evaluateValue);
5250
+ };
5251
+ }
4651
5252
  function evaluateBatchExpression(plan, expression, batch, row) {
4652
5253
  if (expression.kind === "literal")
4653
5254
  return expression.value;
4654
5255
  if (expression.kind === "wildcard")
4655
5256
  return 1;
4656
- if (expression.kind === "list")
4657
- throw new TypeError("Value lists are only supported with IN");
4658
- if (expression.kind === "condition" ||
4659
- expression.kind === "logical" ||
4660
- expression.kind === "not") {
4661
- return booleanTruth(expression, (nested) => evaluateBatchExpression(plan, nested, batch, row));
4662
- }
4663
- if (expression.kind === "case") {
4664
- for (const branch of expression.branches) {
4665
- const matched = booleanTruth(branch.when, (nested) => evaluateBatchExpression(plan, nested, batch, row));
4666
- if (matched === true)
4667
- return evaluateBatchExpression(plan, branch.then, batch, row);
4668
- }
4669
- return expression.otherwise === undefined
4670
- ? null
4671
- : evaluateBatchExpression(plan, expression.otherwise, batch, row);
4672
- }
4673
5257
  if (expression.kind === "column") {
4674
5258
  return vectorValue(expression.vector, batch.rowsBySource[expression.source]?.[row] ?? -1);
4675
5259
  }
4676
- if (expression.kind === "fts") {
4677
- return expression.op === "match"
4678
- ? ftsBatchTruth(expression, batch, null, row)
4679
- : ftsBm25BatchValue(expression, batch, null, row);
4680
- }
4681
- if (expression.kind === "binary") {
4682
- return binaryValue(expression.operator, evaluateBatchExpression(plan, expression.left, batch, row), evaluateBatchExpression(plan, expression.right, batch, row));
4683
- }
4684
- if (expression.name === "COALESCE") {
4685
- for (const argument of expression.arguments) {
4686
- const candidate = evaluateBatchExpression(plan, argument, batch, row);
4687
- if (candidate !== null && candidate !== undefined)
4688
- return candidate;
4689
- }
4690
- return null;
4691
- }
4692
- if (!isScalarFunctionName(expression.name))
4693
- throw new TypeError(`${expression.name} requires grouped execution`);
4694
- return scalarFunctionValue(expression.name, expression.arguments.map((argument) => evaluateBatchExpression(plan, argument, batch, row)));
5260
+ return compiledBatchExpression(plan, expression)(batch, row);
4695
5261
  }
4696
5262
  function evaluateExpression(expression, rowsBySource) {
4697
5263
  if (expression.kind === "literal")
@@ -4741,12 +5307,20 @@ function evaluateExpression(expression, rowsBySource) {
4741
5307
  function binaryValue(operator, left, right) {
4742
5308
  if (left === null || left === undefined || right === null || right === undefined)
4743
5309
  return null;
4744
- if (operator === "||") {
4745
- if (typeof left !== "string" || typeof right !== "string") {
4746
- throw new TypeError("|| requires string operands");
4747
- }
5310
+ if (typeof left === "number" && typeof right === "number") {
5311
+ if (operator === "+")
5312
+ return left + right;
5313
+ if (operator === "-")
5314
+ return left - right;
5315
+ if (operator === "*")
5316
+ return left * right;
5317
+ if (operator === "/")
5318
+ return right === 0 ? null : left / right;
5319
+ if (operator === "%")
5320
+ return right === 0 ? null : left % right;
5321
+ }
5322
+ if (operator === "||")
4748
5323
  return concatenatedSqlValue(left, right);
4749
- }
4750
5324
  const exact = exactNumericBinary(operator, left, right);
4751
5325
  if (exact !== undefined)
4752
5326
  return exact;
@@ -4806,6 +5380,26 @@ function comparisonValue(operator, leftValue, rightValue) {
4806
5380
  rightValue === undefined) {
4807
5381
  return false;
4808
5382
  }
5383
+ // NaN keeps the general path: SQL treats it as one value equal to itself.
5384
+ if (typeof leftValue === "number" &&
5385
+ typeof rightValue === "number" &&
5386
+ leftValue === leftValue &&
5387
+ rightValue === rightValue) {
5388
+ if (operator === "=")
5389
+ return leftValue === rightValue;
5390
+ if (operator === "!=" || operator === "<>")
5391
+ return leftValue !== rightValue;
5392
+ if (operator === ">")
5393
+ return leftValue > rightValue;
5394
+ if (operator === ">=")
5395
+ return leftValue >= rightValue;
5396
+ if (operator === "<")
5397
+ return leftValue < rightValue;
5398
+ return leftValue <= rightValue;
5399
+ }
5400
+ // An untyped string beside a datetime, number, or boolean reads in that value's type, as
5401
+ // PostgreSQL types an unknown-typed literal by its context; the row executor does the same.
5402
+ [leftValue, rightValue] = coerceComparisonOperands(leftValue, rightValue);
4809
5403
  const left = comparable(leftValue);
4810
5404
  const right = comparable(rightValue);
4811
5405
  if (operator === "=")