@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.
@@ -1,7 +1,8 @@
1
1
  import { dateIsoString, dateMilliseconds } from "../date-value.js";
2
2
  import { crossJoinPlan } from "../plan/model.js";
3
- import { blockHasSubqueries, childExpressions, DUAL_TABLE, expressionAliases, forEachBlockExpression, forEachNestedBlock, hasAggregate, isAggregateCall, isScalarFunctionName, mapBlockExpressions, mapChildExpressions, parseQuantified, scalarFunctionNames, scalarFunctionValue, splitCondition, volatileScalarFunctionNames, } from "./query.js";
3
+ import { blockHasSubqueries, childExpressions, DUAL_TABLE, expressionAliases, forEachBlockExpression, forEachNestedBlock, hasAggregate, isAggregateCall, isScalarFunctionName, mapBlockExpressions, mapChildExpressions, parseQuantified, scalarFunctionNames, scalarFunctionValue, splitCondition, volatileScalarFunctionNames, transparentProjectionSource, dateTruncValue, } from "./query.js";
4
4
  import { concatenatedSqlValue, isSqlDomainValue } from "./sql-domains.js";
5
+ import { simpleScalarFunctions } from "./sql-functions.js";
5
6
  /**
6
7
  * Deterministic plan-to-plan rewrites over the shared compiled representation. Every rule
7
8
  * preserves result semantics exactly; rules that cannot prove safety leave the plan unchanged.
@@ -175,52 +176,145 @@ function optimizeBlock(block, nextCorrelationAlias) {
175
176
  extractJoinKeys(block);
176
177
  normalizeBooleanPredicates(block);
177
178
  coalesceOrEqualityLists(block);
179
+ rewriteCalendarEqualities(block);
180
+ propagateJoinKeyConstants(block);
178
181
  pushPredicatesIntoDerived(block);
179
182
  pruneDerivedProjections(block);
180
183
  combineDerivedLimit(block);
181
184
  }
182
- /** Converts correlated LATERAL derived tables into ordinary set-at-a-time joins. */
185
+ /**
186
+ * Converts correlated LATERAL derived tables into ordinary set-at-a-time joins. The inner
187
+ * block's correlation predicates become join keys; its grouping, per-row ordering, and LIMIT
188
+ * are kept by grouping on the keys and ranking within them, the same way a correlated scalar
189
+ * subquery is rewritten:
190
+ *
191
+ * - `GROUP BY` gains the equality keys, so each outer row still sees only its own group.
192
+ * - A global aggregate (`SELECT COUNT(*) …` with no GROUP BY) always yields one row per outer
193
+ * row, so the join becomes a left join and the outer block reads each aggregate through
194
+ * `CASE WHEN <key> IS NULL THEN <empty value> ELSE <column> END`: COUNT reads 0, others NULL.
195
+ * - `ORDER BY … LIMIT` becomes a row number partitioned by the keys, filtered to the window.
196
+ *
197
+ * Range correlations (`o.amount > c.threshold`) cannot be grouped or partitioned on, so those
198
+ * shapes keep the plain set-at-a-time join and are refused with grouping or LIMIT.
199
+ */
183
200
  function decorrelateLateralSources(block) {
184
201
  if (block.base.lateral === true) {
185
202
  throw new TypeError("LATERAL needs a source to its left");
186
203
  }
187
204
  const available = new Set([block.base.alias]);
205
+ let lateralSequence = 0;
188
206
  for (const join of block.joins) {
189
207
  if (join.lateral !== true) {
190
208
  available.add(join.alias);
191
209
  continue;
192
210
  }
193
211
  delete join.lateral;
194
- const inner = join.derived;
195
- if (inner === undefined)
212
+ const derived = join.derived;
213
+ if (derived === undefined)
196
214
  throw new TypeError("LATERAL requires a derived query");
197
- if (!blockReferencesOutside(inner)) {
215
+ if (!blockReferencesOutside(derived)) {
198
216
  available.add(join.alias);
199
217
  continue;
200
218
  }
201
- if (inner.groupBy.length > 0 ||
202
- inner.having.length > 0 ||
203
- inner.orderBy.length > 0 ||
204
- inner.limit !== undefined) {
205
- throw new TypeError("Correlated LATERAL queries cannot use grouping, HAVING, ORDER BY, or LIMIT");
206
- }
219
+ // An ORDER BY over an unselected expression parses as a projection over the ordered block;
220
+ // the correlation, grouping, and ranking live in that inner block, and the projection is
221
+ // widened afterwards so the join can reach the key columns it adds.
222
+ const wrapper = transparentProjectionSource(derived);
223
+ const inner = wrapper?.inner ?? derived;
207
224
  const keys = extractCorrelation(inner, available, "LATERAL", true);
208
225
  const keyAliases = keys.map((_, index) => `\u0000lateral_key_${String(index + 1)}`);
226
+ const grouped = inner.groupBy.length > 0;
227
+ const globalAggregate = !grouped &&
228
+ inner.select.some((item) => containsAggregateCall(item.expression)) &&
229
+ inner.having.length === 0;
230
+ const ranked = inner.limit !== undefined ||
231
+ inner.limitParameter !== undefined ||
232
+ inner.offset !== undefined ||
233
+ inner.offsetParameter !== undefined;
234
+ const cross = (join.on?.kind === "condition" &&
235
+ join.on.operator === "=" &&
236
+ join.on.left.kind === "literal" &&
237
+ join.on.left.value === 1 &&
238
+ join.on.right.kind === "literal" &&
239
+ join.on.right.value === 1) ||
240
+ (join.on?.kind === "literal" && join.on.value === true);
241
+ if (grouped || globalAggregate || ranked || inner.having.length > 0) {
242
+ if (!keys.every((key) => key.operator === "=")) {
243
+ throw new TypeError("Correlated LATERAL queries with grouping, HAVING, ORDER BY, or LIMIT need equality correlations");
244
+ }
245
+ if (inner.having.length > 0 && !grouped) {
246
+ throw new TypeError("HAVING in a correlated LATERAL query needs GROUP BY");
247
+ }
248
+ }
249
+ if (grouped || globalAggregate) {
250
+ for (const key of keys)
251
+ inner.groupBy.push(structuredClone(key.inner));
252
+ }
209
253
  keys.forEach((key, index) => {
210
254
  inner.select.push({ expression: key.inner, alias: keyAliases[index] ?? "" });
211
255
  });
256
+ if (ranked) {
257
+ lateralSequence += 1;
258
+ const rankedRows = rankLateralRows(inner, keyAliases, lateralSequence);
259
+ if (wrapper === undefined)
260
+ join.derived = rankedRows;
261
+ else
262
+ derived.base.derived = rankedRows;
263
+ }
264
+ else {
265
+ inner.orderBy = [];
266
+ }
267
+ if (wrapper !== undefined) {
268
+ for (const keyAlias of keyAliases) {
269
+ derived.select.push({
270
+ expression: { kind: "column", reference: `${derived.base.alias}.${keyAlias}` },
271
+ alias: keyAlias,
272
+ });
273
+ }
274
+ }
275
+ if (globalAggregate) {
276
+ if (!cross) {
277
+ throw new TypeError("A correlated LATERAL aggregate without GROUP BY needs ON TRUE or a comma join");
278
+ }
279
+ join.kind = "left";
280
+ const keyReference = {
281
+ kind: "column",
282
+ reference: `${join.alias}.${keyAliases[0] ?? ""}`,
283
+ };
284
+ const replacements = new Map();
285
+ const visible = new Set(derived.select.map((item) => item.alias));
286
+ for (const item of inner.select) {
287
+ if (item.alias.startsWith("\u0000") || !visible.has(item.alias))
288
+ continue;
289
+ const empty = emptyScalarExpression(structuredClone(item.expression));
290
+ if (empty.kind === "literal" && empty.value === null)
291
+ continue;
292
+ replacements.set(`${join.alias}.${item.alias}`, {
293
+ kind: "case",
294
+ branches: [
295
+ {
296
+ when: {
297
+ kind: "condition",
298
+ operator: "IS NULL",
299
+ left: structuredClone(keyReference),
300
+ right: { kind: "literal", value: null },
301
+ },
302
+ then: empty,
303
+ },
304
+ ],
305
+ otherwise: { kind: "column", reference: `${join.alias}.${item.alias}` },
306
+ });
307
+ }
308
+ if (replacements.size > 0) {
309
+ replaceOuterReferences(block, join.alias, replacements);
310
+ }
311
+ }
212
312
  const correlations = keys.map((key, index) => ({
213
313
  kind: "condition",
214
314
  operator: reverseComparison(key.operator),
215
315
  left: key.outer,
216
316
  right: { kind: "column", reference: `${join.alias}.${keyAliases[index] ?? ""}` },
217
317
  }));
218
- const cross = join.on?.kind === "condition" &&
219
- join.on.operator === "=" &&
220
- join.on.left.kind === "literal" &&
221
- join.on.left.value === 1 &&
222
- join.on.right.kind === "literal" &&
223
- join.on.right.value === 1;
224
318
  const original = cross
225
319
  ? undefined
226
320
  : (join.on ?? {
@@ -252,6 +346,127 @@ function decorrelateLateralSources(block) {
252
346
  available.add(join.alias);
253
347
  }
254
348
  }
349
+ /**
350
+ * Wraps a LATERAL inner block whose ORDER BY/LIMIT apply per outer row: the block's order
351
+ * expressions project as hidden columns, a row number (RANK for WITH TIES) partitioned by the
352
+ * correlation keys ranks them, and a filtering block keeps the requested window. The result
353
+ * exposes the inner block's own aliases, so the outer query's references still resolve.
354
+ */
355
+ function rankLateralRows(inner, keyAliases, sequence) {
356
+ // An order term that names one of the block's own output aliases (the parser's hidden
357
+ // "(order n)" projections included) ranks on that column; any other term projects as a
358
+ // hidden column of its own.
359
+ const selectAliases = new Set(inner.select.map((item) => item.alias));
360
+ const orderAliases = inner.orderBy.map((order, index) => {
361
+ if (order.expression.kind === "column" &&
362
+ !order.expression.reference.includes(".") &&
363
+ selectAliases.has(order.expression.reference)) {
364
+ return order.expression.reference;
365
+ }
366
+ const alias = `\u0000lateral_order_${String(sequence)}_${String(index + 1)}`;
367
+ inner.select.push({ expression: structuredClone(order.expression), alias });
368
+ return alias;
369
+ });
370
+ const windows = [
371
+ {
372
+ alias: `\u0000lateral_row_${String(sequence)}`,
373
+ name: inner.limitWithTies === true ? "RANK" : "ROW_NUMBER",
374
+ partitionAliases: [...keyAliases],
375
+ orderAliases: inner.orderBy.map((order, index) => ({
376
+ alias: orderAliases[index] ?? "",
377
+ direction: order.direction,
378
+ ...(order.nulls === undefined ? {} : { nulls: order.nulls }),
379
+ })),
380
+ },
381
+ ];
382
+ const { limit, limitParameter, offset, offsetParameter, limitWithTies, ...rows } = inner;
383
+ void limitWithTies;
384
+ rows.orderBy = [];
385
+ const rankedAlias = `\u0000lateral_ranked_${String(sequence)}`;
386
+ const rowNumber = {
387
+ kind: "column",
388
+ reference: `${rankedAlias}.${windows[0]?.alias ?? ""}`,
389
+ };
390
+ const offsetExpression = offsetParameter === undefined
391
+ ? { kind: "literal", value: offset ?? 0 }
392
+ : { kind: "parameter", index: offsetParameter };
393
+ const predicates = [];
394
+ if ((offset ?? 0) > 0 || offsetParameter !== undefined) {
395
+ predicates.push({ left: rowNumber, operator: ">", right: offsetExpression });
396
+ }
397
+ if (limit !== undefined || limitParameter !== undefined) {
398
+ const limitExpression = limitParameter === undefined
399
+ ? { kind: "literal", value: limit ?? 0 }
400
+ : { kind: "parameter", index: limitParameter };
401
+ predicates.push({
402
+ left: structuredClone(rowNumber),
403
+ operator: "<=",
404
+ right: offset === undefined && offsetParameter === undefined
405
+ ? limitExpression
406
+ : {
407
+ kind: "binary",
408
+ operator: "+",
409
+ left: structuredClone(offsetExpression),
410
+ right: limitExpression,
411
+ },
412
+ });
413
+ }
414
+ return {
415
+ sql: "(lateral ranked rows)",
416
+ base: { table: rankedAlias, alias: rankedAlias, windowed: { block: rows, windows } },
417
+ joins: [],
418
+ select: rows.select.map((item) => ({
419
+ expression: { kind: "column", reference: `${rankedAlias}.${item.alias}` },
420
+ alias: item.alias,
421
+ })),
422
+ predicates,
423
+ groupBy: [],
424
+ having: [],
425
+ orderBy: [],
426
+ ...(limitParameter === undefined ? {} : { limitValidationParameters: [limitParameter] }),
427
+ ...(offsetParameter === undefined ? {} : { offsetValidationParameters: [offsetParameter] }),
428
+ };
429
+ }
430
+ /**
431
+ * Replaces the outer block's references to one join alias's columns — everywhere except the
432
+ * join's own key expressions, which must keep addressing the raw column.
433
+ */
434
+ function replaceOuterReferences(block, alias, replacements) {
435
+ const rewrite = (expression) => {
436
+ if (expression.kind === "column") {
437
+ const replacement = replacements.get(expression.reference);
438
+ return replacement === undefined ? expression : structuredClone(replacement);
439
+ }
440
+ if (expression.kind === "subquery" || expression.kind === "exists") {
441
+ replaceOuterReferences(expression.block, alias, replacements);
442
+ return expression;
443
+ }
444
+ if (expression.kind === "window") {
445
+ expression.partitionBy = expression.partitionBy.map(rewrite);
446
+ for (const order of expression.orderBy)
447
+ order.expression = rewrite(order.expression);
448
+ if (expression.argument !== undefined)
449
+ expression.argument = rewrite(expression.argument);
450
+ return expression;
451
+ }
452
+ return mapChildExpressions(expression, rewrite);
453
+ };
454
+ for (const item of block.select)
455
+ item.expression = rewrite(item.expression);
456
+ for (const predicate of [...block.predicates, ...block.having]) {
457
+ predicate.left = rewrite(predicate.left);
458
+ predicate.right = rewrite(predicate.right);
459
+ }
460
+ block.groupBy = block.groupBy.map(rewrite);
461
+ for (const order of block.orderBy)
462
+ order.expression = rewrite(order.expression);
463
+ for (const join of block.joins) {
464
+ if (join.alias === alias)
465
+ continue;
466
+ if (join.on !== undefined)
467
+ join.on = rewrite(join.on);
468
+ }
469
+ }
255
470
  // --- Join key extraction ---------------------------------------------------------------------
256
471
  /**
257
472
  * Pulls a hash key out of a conjunctive ON clause.
@@ -289,6 +504,215 @@ function extractJoinKeys(block) {
289
504
  available.add(join.alias);
290
505
  }
291
506
  }
507
+ /**
508
+ * `DATE_TRUNC('unit', col) = ts` and `EXTRACT(YEAR FROM col) = n` are equalities on a derived
509
+ * value that the scan cannot use; the same truth as a range on the column itself — `col >=
510
+ * start AND col < start + 1 unit` — reads by value range, prunes blocks by their statistics,
511
+ * and runs on the raw datetime kernel. A timestamp that is not aligned to its unit can never
512
+ * equal a truncation, so that predicate becomes a constant false.
513
+ */
514
+ function rewriteCalendarEqualities(block) {
515
+ const rewritten = [];
516
+ for (const predicate of block.predicates) {
517
+ if (predicate.operator !== "=") {
518
+ rewritten.push(predicate);
519
+ continue;
520
+ }
521
+ let call;
522
+ let literal;
523
+ if (predicate.left.kind === "call" && predicate.right.kind === "literal") {
524
+ call = predicate.left;
525
+ literal = predicate.right;
526
+ }
527
+ else if (predicate.left.kind === "literal" && predicate.right.kind === "call") {
528
+ call = predicate.right;
529
+ literal = predicate.left;
530
+ }
531
+ if (call === undefined || literal === undefined) {
532
+ rewritten.push(predicate);
533
+ continue;
534
+ }
535
+ const range = calendarRange(call, literal);
536
+ if (range === undefined) {
537
+ rewritten.push(predicate);
538
+ continue;
539
+ }
540
+ if (range === "never") {
541
+ rewritten.push({
542
+ left: { kind: "literal", value: 1 },
543
+ operator: "=",
544
+ right: { kind: "literal", value: 0 },
545
+ });
546
+ continue;
547
+ }
548
+ rewritten.push({
549
+ left: structuredClone(range.column),
550
+ operator: ">=",
551
+ right: { kind: "literal", value: range.start },
552
+ }, {
553
+ left: structuredClone(range.column),
554
+ operator: "<",
555
+ right: { kind: "literal", value: range.end },
556
+ });
557
+ }
558
+ block.predicates = rewritten;
559
+ }
560
+ function calendarRange(call, literal) {
561
+ if (call.name === "DATE_TRUNC") {
562
+ const [unit, column] = call.arguments;
563
+ if (unit?.kind !== "literal" ||
564
+ typeof unit.value !== "string" ||
565
+ column?.kind !== "column" ||
566
+ !(literal.value instanceof Date)) {
567
+ return undefined;
568
+ }
569
+ const normalized = unit.value.toLowerCase();
570
+ if (!calendarUnitSteps.has(normalized))
571
+ return undefined;
572
+ const start = dateTruncValue(normalized, literal.value);
573
+ if (start === null || Number.isNaN(start.getTime()))
574
+ return undefined;
575
+ if (start.getTime() !== literal.value.getTime())
576
+ return "never";
577
+ return { column, start, end: addCalendarUnit(start, normalized) };
578
+ }
579
+ if (call.name === "EXTRACT") {
580
+ const [field, column] = call.arguments;
581
+ if (field?.kind !== "literal" ||
582
+ typeof field.value !== "string" ||
583
+ field.value.toLowerCase() !== "year" ||
584
+ column?.kind !== "column" ||
585
+ typeof literal.value !== "number") {
586
+ return undefined;
587
+ }
588
+ const year = literal.value;
589
+ if (!Number.isInteger(year) || year < 1 || year > 9999)
590
+ return "never";
591
+ return {
592
+ column,
593
+ start: new Date(Date.UTC(year, 0, 1)),
594
+ end: new Date(Date.UTC(year + 1, 0, 1)),
595
+ };
596
+ }
597
+ return undefined;
598
+ }
599
+ const calendarUnitSteps = new Set([
600
+ "year",
601
+ "quarter",
602
+ "month",
603
+ "week",
604
+ "day",
605
+ "hour",
606
+ "minute",
607
+ "second",
608
+ ]);
609
+ /** The instant one calendar unit after an aligned UTC instant. */
610
+ function addCalendarUnit(start, unit) {
611
+ const year = start.getUTCFullYear();
612
+ const month = start.getUTCMonth();
613
+ const day = start.getUTCDate();
614
+ switch (unit) {
615
+ case "year":
616
+ return new Date(Date.UTC(year + 1, 0, 1));
617
+ case "quarter":
618
+ return new Date(Date.UTC(year, month + 3, 1));
619
+ case "month":
620
+ return new Date(Date.UTC(year, month + 1, 1));
621
+ case "week":
622
+ return new Date(start.getTime() + 7 * 86_400_000);
623
+ case "day":
624
+ return new Date(Date.UTC(year, month, day + 1));
625
+ case "hour":
626
+ return new Date(start.getTime() + 3_600_000);
627
+ case "minute":
628
+ return new Date(start.getTime() + 60_000);
629
+ default:
630
+ return new Date(start.getTime() + 1000);
631
+ }
632
+ }
633
+ /**
634
+ * An inner equi-join makes its two key columns equal on every output row, so a constant
635
+ * equality or IN list on one key holds for the other: `c.id = 4 AND o.customer = c.id` implies
636
+ * `o.customer = 4`. The implied predicate is added (never substituted), which lets the table
637
+ * that only carried the join key filter its own scan — through a secondary index when it has
638
+ * one — instead of joining first and filtering after. Only inner joins qualify: an outer join
639
+ * keeps rows whose key is NULL, for which the implication fails.
640
+ */
641
+ function propagateJoinKeyConstants(block) {
642
+ const earlier = new Set([block.base.alias]);
643
+ const pairs = [];
644
+ for (const join of block.joins) {
645
+ const ownAlias = join.alias;
646
+ // Only joins that drop an unmatched outer row qualify: a mirrored predicate is applied to
647
+ // the join's output, where a LEFT or anti join's null-extended row would fail it.
648
+ if (join.on === undefined &&
649
+ join.left.kind === "column" &&
650
+ join.right.kind === "column" &&
651
+ (join.kind === "inner" || join.kind === "semi")) {
652
+ const leftOwn = join.left.reference.startsWith(`${ownAlias}.`);
653
+ const rightOwn = join.right.reference.startsWith(`${ownAlias}.`);
654
+ if (leftOwn !== rightOwn) {
655
+ const inner = leftOwn ? join.left.reference : join.right.reference;
656
+ const outer = leftOwn ? join.right.reference : join.left.reference;
657
+ const outerAlias = outer.slice(0, outer.indexOf("."));
658
+ // Both directions hold: the key values are equal on every surviving row, and a row
659
+ // without a match on either side is dropped anyway.
660
+ if (earlier.has(outerAlias))
661
+ pairs.push({ inner, outer, toInner: true, toOuter: true });
662
+ }
663
+ }
664
+ earlier.add(ownAlias);
665
+ }
666
+ if (pairs.length === 0)
667
+ return;
668
+ const signature = (predicate) => JSON.stringify(predicate);
669
+ const present = new Set(block.predicates.map(signature));
670
+ const implied = [];
671
+ const constantSide = (predicate) => {
672
+ if (predicate.operator === "IN") {
673
+ return predicate.left.kind === "column" &&
674
+ predicate.right.kind === "list" &&
675
+ predicate.right.items.every((item) => item.kind === "literal")
676
+ ? "right"
677
+ : undefined;
678
+ }
679
+ if (!rangeOrEqualityOperators.has(predicate.operator))
680
+ return undefined;
681
+ if (predicate.left.kind === "column" && predicate.right.kind === "literal")
682
+ return "right";
683
+ if (predicate.right.kind === "column" && predicate.left.kind === "literal")
684
+ return "left";
685
+ return undefined;
686
+ };
687
+ for (const predicate of block.predicates) {
688
+ const side = constantSide(predicate);
689
+ if (side === undefined)
690
+ continue;
691
+ const column = side === "right" ? predicate.left : predicate.right;
692
+ if (column.kind !== "column")
693
+ continue;
694
+ for (const pair of pairs) {
695
+ const target = column.reference === pair.outer && pair.toInner
696
+ ? pair.inner
697
+ : column.reference === pair.inner && pair.toOuter
698
+ ? pair.outer
699
+ : undefined;
700
+ if (target === undefined)
701
+ continue;
702
+ const replacement = { kind: "column", reference: target };
703
+ const mirrored = side === "right"
704
+ ? { ...predicate, left: replacement, right: structuredClone(predicate.right) }
705
+ : { ...predicate, left: structuredClone(predicate.left), right: replacement };
706
+ const key = signature(mirrored);
707
+ if (present.has(key))
708
+ continue;
709
+ present.add(key);
710
+ implied.push(mirrored);
711
+ }
712
+ }
713
+ block.predicates.push(...implied);
714
+ }
715
+ const rangeOrEqualityOperators = new Set(["=", "<", "<=", ">", ">="]);
292
716
  /**
293
717
  * Whether an equality's two sides split cleanly along this join: one side reads only the table
294
718
  * being joined, the other only tables already in hand. Anything else — a side spanning both, a
@@ -2172,7 +2596,11 @@ function foldExpression(expression) {
2172
2596
  expression.name === "JSON_ARRAY" ||
2173
2597
  expression.name === "MINNOW_JSON_GET"
2174
2598
  ? { kind: "json" }
2175
- : undefined;
2599
+ : simpleScalarFunctions.get(expression.name)?.returns === "date"
2600
+ ? { kind: "date" }
2601
+ : simpleScalarFunctions.get(expression.name)?.returns === "interval"
2602
+ ? { kind: "interval" }
2603
+ : undefined;
2176
2604
  return {
2177
2605
  kind: "literal",
2178
2606
  value: folded,
@@ -9,10 +9,11 @@ export interface PointReadShape {
9
9
  /** Conjunctive equalities, in predicate order; may repeat a column. */
10
10
  equalities: PointReadEquality[];
11
11
  /** Plain column projections, in select order. */
12
+ /** Projected columns, or "*" for a bare wildcard the catalog expands at execution. */
12
13
  select: Array<{
13
14
  column: string;
14
15
  alias: string;
15
- }>;
16
+ }> | "*";
16
17
  }
17
18
  /** The statement-shaped half of the analysis, computed once per cached plan. */
18
19
  interface PointReadTemplate {
@@ -24,10 +25,11 @@ interface PointReadTemplate {
24
25
  column: string;
25
26
  parameter: number;
26
27
  }>;
28
+ /** Projected columns, or "*" for a bare wildcard the catalog expands at execution. */
27
29
  select: Array<{
28
30
  column: string;
29
31
  alias: string;
30
- }>;
32
+ }> | "*";
31
33
  }
32
34
  /**
33
35
  * Test-only escape hatch and counters. Not exported from any public entry point: in-repo
@@ -93,12 +93,22 @@ function pointReadTemplate(plan) {
93
93
  plan.usesVolatileFunctions === true) {
94
94
  return undefined;
95
95
  }
96
- const select = [];
97
- for (const item of plan.select) {
98
- const column = baseColumnReference(item.expression, base.alias);
99
- if (column === undefined)
100
- return undefined;
101
- select.push({ column, alias: item.alias });
96
+ let select = [];
97
+ const first = plan.select[0];
98
+ if (plan.select.length === 1 &&
99
+ first?.expression.kind === "wildcard" &&
100
+ (first.expression.table === undefined || first.expression.table === base.alias)) {
101
+ // `SELECT * FROM t WHERE key = ?` is the commonest point lookup; the catalog names the
102
+ // columns when the read is served, in declaration order like the ordinary executor.
103
+ select = "*";
104
+ }
105
+ else {
106
+ for (const item of plan.select) {
107
+ const column = baseColumnReference(item.expression, base.alias);
108
+ if (column === undefined)
109
+ return undefined;
110
+ select.push({ column, alias: item.alias });
111
+ }
102
112
  }
103
113
  const equalities = [];
104
114
  for (const predicate of plan.predicates) {