@quereus/quereus 4.16.0 → 4.17.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.
Files changed (163) hide show
  1. package/dist/src/core/database-auto-analyze.d.ts +258 -0
  2. package/dist/src/core/database-auto-analyze.d.ts.map +1 -0
  3. package/dist/src/core/database-auto-analyze.js +616 -0
  4. package/dist/src/core/database-auto-analyze.js.map +1 -0
  5. package/dist/src/core/database-materialized-views.d.ts +42 -1
  6. package/dist/src/core/database-materialized-views.d.ts.map +1 -1
  7. package/dist/src/core/database-materialized-views.js +76 -3
  8. package/dist/src/core/database-materialized-views.js.map +1 -1
  9. package/dist/src/core/database-transaction.d.ts +17 -0
  10. package/dist/src/core/database-transaction.d.ts.map +1 -1
  11. package/dist/src/core/database-transaction.js +37 -0
  12. package/dist/src/core/database-transaction.js.map +1 -1
  13. package/dist/src/core/database.d.ts +8 -1
  14. package/dist/src/core/database.d.ts.map +1 -1
  15. package/dist/src/core/database.js +135 -5
  16. package/dist/src/core/database.js.map +1 -1
  17. package/dist/src/planner/analysis/change-scope.d.ts.map +1 -1
  18. package/dist/src/planner/analysis/change-scope.js +8 -8
  19. package/dist/src/planner/analysis/change-scope.js.map +1 -1
  20. package/dist/src/planner/analysis/constraint-extractor.d.ts.map +1 -1
  21. package/dist/src/planner/analysis/constraint-extractor.js +27 -14
  22. package/dist/src/planner/analysis/constraint-extractor.js.map +1 -1
  23. package/dist/src/planner/analysis/expression-fingerprint.d.ts.map +1 -1
  24. package/dist/src/planner/analysis/expression-fingerprint.js +23 -2
  25. package/dist/src/planner/analysis/expression-fingerprint.js.map +1 -1
  26. package/dist/src/planner/analysis/mv-body-functions.d.ts +46 -0
  27. package/dist/src/planner/analysis/mv-body-functions.d.ts.map +1 -0
  28. package/dist/src/planner/analysis/mv-body-functions.js +65 -0
  29. package/dist/src/planner/analysis/mv-body-functions.js.map +1 -0
  30. package/dist/src/planner/analysis/predicate-shape.d.ts +32 -4
  31. package/dist/src/planner/analysis/predicate-shape.d.ts.map +1 -1
  32. package/dist/src/planner/analysis/predicate-shape.js +37 -5
  33. package/dist/src/planner/analysis/predicate-shape.js.map +1 -1
  34. package/dist/src/planner/analysis/query-rewrite-matcher.d.ts +2 -2
  35. package/dist/src/planner/analysis/query-rewrite-matcher.d.ts.map +1 -1
  36. package/dist/src/planner/analysis/query-rewrite-matcher.js +46 -6
  37. package/dist/src/planner/analysis/query-rewrite-matcher.js.map +1 -1
  38. package/dist/src/planner/analysis/sat-checker.js +2 -5
  39. package/dist/src/planner/analysis/sat-checker.js.map +1 -1
  40. package/dist/src/planner/building/select-aggregates.d.ts +17 -5
  41. package/dist/src/planner/building/select-aggregates.d.ts.map +1 -1
  42. package/dist/src/planner/building/select-aggregates.js +122 -79
  43. package/dist/src/planner/building/select-aggregates.js.map +1 -1
  44. package/dist/src/planner/building/select-modifiers.d.ts +1 -1
  45. package/dist/src/planner/building/select.d.ts.map +1 -1
  46. package/dist/src/planner/building/select.js +73 -55
  47. package/dist/src/planner/building/select.js.map +1 -1
  48. package/dist/src/planner/cost/index.d.ts +14 -0
  49. package/dist/src/planner/cost/index.d.ts.map +1 -1
  50. package/dist/src/planner/cost/index.js +19 -0
  51. package/dist/src/planner/cost/index.js.map +1 -1
  52. package/dist/src/planner/nodes/limit-offset.d.ts.map +1 -1
  53. package/dist/src/planner/nodes/limit-offset.js +3 -2
  54. package/dist/src/planner/nodes/limit-offset.js.map +1 -1
  55. package/dist/src/planner/optimizer-tuning.d.ts +10 -6
  56. package/dist/src/planner/optimizer-tuning.d.ts.map +1 -1
  57. package/dist/src/planner/optimizer-tuning.js.map +1 -1
  58. package/dist/src/planner/optimizer.d.ts.map +1 -1
  59. package/dist/src/planner/optimizer.js +24 -0
  60. package/dist/src/planner/optimizer.js.map +1 -1
  61. package/dist/src/planner/rules/access/rule-monotonic-range-access.d.ts.map +1 -1
  62. package/dist/src/planner/rules/access/rule-monotonic-range-access.js +8 -4
  63. package/dist/src/planner/rules/access/rule-monotonic-range-access.js.map +1 -1
  64. package/dist/src/planner/rules/access/rule-select-access-path.d.ts +14 -0
  65. package/dist/src/planner/rules/access/rule-select-access-path.d.ts.map +1 -1
  66. package/dist/src/planner/rules/access/rule-select-access-path.js +5 -1
  67. package/dist/src/planner/rules/access/rule-select-access-path.js.map +1 -1
  68. package/dist/src/planner/rules/aggregate/rule-groupby-fd-simplification.d.ts +3 -1
  69. package/dist/src/planner/rules/aggregate/rule-groupby-fd-simplification.d.ts.map +1 -1
  70. package/dist/src/planner/rules/aggregate/rule-groupby-fd-simplification.js +8 -3
  71. package/dist/src/planner/rules/aggregate/rule-groupby-fd-simplification.js.map +1 -1
  72. package/dist/src/planner/rules/aggregate/rule-minmax-index-boundary.d.ts +47 -0
  73. package/dist/src/planner/rules/aggregate/rule-minmax-index-boundary.d.ts.map +1 -0
  74. package/dist/src/planner/rules/aggregate/rule-minmax-index-boundary.js +200 -0
  75. package/dist/src/planner/rules/aggregate/rule-minmax-index-boundary.js.map +1 -0
  76. package/dist/src/planner/rules/cache/rule-nested-loop-right-cache.d.ts +29 -0
  77. package/dist/src/planner/rules/cache/rule-nested-loop-right-cache.d.ts.map +1 -1
  78. package/dist/src/planner/rules/cache/rule-nested-loop-right-cache.js +55 -16
  79. package/dist/src/planner/rules/cache/rule-nested-loop-right-cache.js.map +1 -1
  80. package/dist/src/planner/rules/join/index-nested-loop.d.ts +96 -22
  81. package/dist/src/planner/rules/join/index-nested-loop.d.ts.map +1 -1
  82. package/dist/src/planner/rules/join/index-nested-loop.js +401 -82
  83. package/dist/src/planner/rules/join/index-nested-loop.js.map +1 -1
  84. package/dist/src/planner/rules/join/rule-fanout-batched-outer.d.ts +20 -0
  85. package/dist/src/planner/rules/join/rule-fanout-batched-outer.d.ts.map +1 -1
  86. package/dist/src/planner/rules/join/rule-fanout-batched-outer.js +23 -4
  87. package/dist/src/planner/rules/join/rule-fanout-batched-outer.js.map +1 -1
  88. package/dist/src/planner/rules/join/rule-join-greedy-commute.d.ts.map +1 -1
  89. package/dist/src/planner/rules/join/rule-join-greedy-commute.js +7 -0
  90. package/dist/src/planner/rules/join/rule-join-greedy-commute.js.map +1 -1
  91. package/dist/src/planner/rules/join/rule-join-physical-selection.d.ts +19 -2
  92. package/dist/src/planner/rules/join/rule-join-physical-selection.d.ts.map +1 -1
  93. package/dist/src/planner/rules/join/rule-join-physical-selection.js +262 -40
  94. package/dist/src/planner/rules/join/rule-join-physical-selection.js.map +1 -1
  95. package/dist/src/planner/rules/predicate/rule-sargable-range-rewrite.d.ts.map +1 -1
  96. package/dist/src/planner/rules/predicate/rule-sargable-range-rewrite.js +2 -1
  97. package/dist/src/planner/rules/predicate/rule-sargable-range-rewrite.js.map +1 -1
  98. package/dist/src/planner/rules/retrieve/rule-grow-retrieve.d.ts +21 -0
  99. package/dist/src/planner/rules/retrieve/rule-grow-retrieve.d.ts.map +1 -1
  100. package/dist/src/planner/rules/retrieve/rule-grow-retrieve.js +24 -6
  101. package/dist/src/planner/rules/retrieve/rule-grow-retrieve.js.map +1 -1
  102. package/dist/src/planner/rules/shared/access-leaf.d.ts +12 -12
  103. package/dist/src/planner/rules/shared/access-leaf.d.ts.map +1 -1
  104. package/dist/src/planner/rules/shared/access-leaf.js +3 -14
  105. package/dist/src/planner/rules/shared/access-leaf.js.map +1 -1
  106. package/dist/src/planner/stats/catalog-stats.d.ts.map +1 -1
  107. package/dist/src/planner/stats/catalog-stats.js +6 -10
  108. package/dist/src/planner/stats/catalog-stats.js.map +1 -1
  109. package/dist/src/planner/type-utils.d.ts.map +1 -1
  110. package/dist/src/planner/type-utils.js +3 -5
  111. package/dist/src/planner/type-utils.js.map +1 -1
  112. package/dist/src/planner/util/fd-utils.d.ts.map +1 -1
  113. package/dist/src/planner/util/fd-utils.js +4 -7
  114. package/dist/src/planner/util/fd-utils.js.map +1 -1
  115. package/dist/src/runtime/emit/alter-table.d.ts.map +1 -1
  116. package/dist/src/runtime/emit/alter-table.js +58 -4
  117. package/dist/src/runtime/emit/alter-table.js.map +1 -1
  118. package/dist/src/runtime/emit/binary.d.ts.map +1 -1
  119. package/dist/src/runtime/emit/binary.js +5 -3
  120. package/dist/src/runtime/emit/binary.js.map +1 -1
  121. package/dist/src/runtime/emit/fanout-lookup-join.d.ts.map +1 -1
  122. package/dist/src/runtime/emit/fanout-lookup-join.js +9 -1
  123. package/dist/src/runtime/emit/fanout-lookup-join.js.map +1 -1
  124. package/dist/src/runtime/emit/limit-offset.d.ts.map +1 -1
  125. package/dist/src/runtime/emit/limit-offset.js +50 -24
  126. package/dist/src/runtime/emit/limit-offset.js.map +1 -1
  127. package/dist/src/runtime/emit/materialized-view-helpers.d.ts +33 -2
  128. package/dist/src/runtime/emit/materialized-view-helpers.d.ts.map +1 -1
  129. package/dist/src/runtime/emit/materialized-view-helpers.js +61 -5
  130. package/dist/src/runtime/emit/materialized-view-helpers.js.map +1 -1
  131. package/dist/src/runtime/emit/materialized-view.d.ts.map +1 -1
  132. package/dist/src/runtime/emit/materialized-view.js +6 -1
  133. package/dist/src/runtime/emit/materialized-view.js.map +1 -1
  134. package/dist/src/runtime/emit/ordinal-slice.d.ts.map +1 -1
  135. package/dist/src/runtime/emit/ordinal-slice.js +8 -0
  136. package/dist/src/runtime/emit/ordinal-slice.js.map +1 -1
  137. package/dist/src/schema/derivation.d.ts +15 -1
  138. package/dist/src/schema/derivation.d.ts.map +1 -1
  139. package/dist/src/schema/derivation.js.map +1 -1
  140. package/dist/src/schema/function.d.ts +9 -4
  141. package/dist/src/schema/function.d.ts.map +1 -1
  142. package/dist/src/schema/function.js +11 -9
  143. package/dist/src/schema/function.js.map +1 -1
  144. package/dist/src/schema/schema.d.ts +14 -3
  145. package/dist/src/schema/schema.d.ts.map +1 -1
  146. package/dist/src/schema/schema.js +20 -2
  147. package/dist/src/schema/schema.js.map +1 -1
  148. package/dist/src/schema/table.d.ts +27 -0
  149. package/dist/src/schema/table.d.ts.map +1 -1
  150. package/dist/src/schema/table.js +55 -0
  151. package/dist/src/schema/table.js.map +1 -1
  152. package/dist/src/schema/unique-enforcement.d.ts +6 -13
  153. package/dist/src/schema/unique-enforcement.d.ts.map +1 -1
  154. package/dist/src/schema/unique-enforcement.js +8 -17
  155. package/dist/src/schema/unique-enforcement.js.map +1 -1
  156. package/dist/src/util/comparison.d.ts +19 -0
  157. package/dist/src/util/comparison.d.ts.map +1 -1
  158. package/dist/src/util/comparison.js +22 -0
  159. package/dist/src/util/comparison.js.map +1 -1
  160. package/dist/src/vtab/memory/module.d.ts.map +1 -1
  161. package/dist/src/vtab/memory/module.js +10 -0
  162. package/dist/src/vtab/memory/module.js.map +1 -1
  163. package/package.json +3 -3
@@ -1,29 +1,50 @@
1
1
  /**
2
2
  * Index-nested-loop candidate construction for `rule-join-physical-selection`.
3
3
  *
4
- * When a join's inner (right) side bottoms out in a plain every-row walk over a
5
- * table whose module can answer an equality seek on the join key, the walk is
6
- * replaced by an `IndexSeekNode` whose seek keys are column references into the
7
- * OUTER row. The node above stays the logical `JoinNode`, so the existing
8
- * nested-loop emitter drives it: for each outer row it installs the outer row
9
- * slot and re-opens the inner pipeline, and the seek-key expressions resolve
10
- * through the runtime context by attribute id — the same machinery correlated
4
+ * When a join's inner side bottoms out in a table-access leaf whose module can
5
+ * answer an equality seek on the join key, the leaf is replaced by an
6
+ * `IndexSeekNode` whose seek keys are column references into the OUTER row.
7
+ * The node above stays the logical `JoinNode`, so the existing nested-loop
8
+ * emitter drives it: for each outer row it installs the outer row slot and
9
+ * re-opens the inner pipeline, and the seek-key expressions resolve through
10
+ * the runtime context by attribute id — the same machinery correlated
11
11
  * subqueries already exercise.
12
12
  *
13
13
  * Join(inner, s, SeqScan(big)) Join(inner, s, IndexSeek(big, keys=[s.k]))
14
14
  * ON big.id = s.k ──▶ ON big.id = s.k
15
15
  * reads every row of big one seek per row of s
16
16
  *
17
+ * Two admission arms for the inner leaf. A WALK leaf (plain full scan, or an
18
+ * ordering-only index walk) enforces nothing, so its `FilterInfo` may be
19
+ * replaced wholesale. A SEEK leaf (`IndexSeekNode`) already enforces the
20
+ * predicates the module claimed — `status = 'x'` below is nowhere else in the
21
+ * tree — so it is admitted by re-OFFERING those predicates to the module
22
+ * alongside the join key and re-applying whatever the module declines:
23
+ *
24
+ * Join(inner, s, IndexSeek(big, [status='x']))
25
+ * ON big.id = s.k
26
+ * ──▶ Join(inner, s, Filter[status='x'](IndexSeek(big, keys=[s.k])))
27
+ * or Join(inner, s, IndexSeek(big, keys=[status='x', s.k])) (composite index)
28
+ *
29
+ * The function takes `outer` / `inner` explicitly rather than reading a
30
+ * JoinNode's `left` / `right`: the caller asks for BOTH orientations of an
31
+ * inner join (seek the right driven by the left, and the mirror) and elects
32
+ * the cheaper — see `ruleJoinPhysicalSelection`. Here "outer" always means the
33
+ * side that will drive, and "inner" the side whose leaf becomes the seek,
34
+ * whichever JoinNode slot each came from.
35
+ *
17
36
  * This module only CONSTRUCTS the candidate (recognize the shape, probe the
18
- * module, cost it); the four-way cost comparison and the join rebuild live in
19
- * the caller. Every gate below declines by returning null, which leaves the
37
+ * module, cost it); the cost comparison and the join rebuild live in the
38
+ * caller. Every gate below declines by returning null, which leaves the
20
39
  * nested-loop / hash / merge competition unchanged.
21
40
  *
22
41
  * NOTE: one extra pair of `getBestAccessPlan` probes runs per qualifying
23
- * equi-join, uncached (see `probeModule`). Cheap for both shipped modules;
24
- * memoize by (table, seek columns) only if a third-party module with an
25
- * expensive planner shows up in optimization profiles — mirrors the
26
- * rule-key-set-seek tripwire.
42
+ * equi-join on the walk arm, uncached (see `probeModule`); the seek arm runs
43
+ * ONE probe — its baseline is the displaced seek's recorded cost, a field read
44
+ * rather than a second probe. Cheap for both shipped modules; memoize by
45
+ * (table, offered constraints) only if a third-party module with an expensive
46
+ * planner shows up in optimization profiles — mirrors the rule-key-set-seek
47
+ * tripwire.
27
48
  */
28
49
  import { createLogger } from '../../../common/logger.js';
29
50
  import { IndexScanNode, IndexSeekNode } from '../../nodes/table-access-nodes.js';
@@ -33,28 +54,42 @@ import { BinaryOpNode } from '../../nodes/scalar.js';
33
54
  import { PlanNodeCharacteristics } from '../../framework/characteristics.js';
34
55
  import { hasSemanticOrdering } from '../../../util/comparison.js';
35
56
  import { sharesSeekKeySpace } from '../../../types/builtin-types.js';
57
+ import { hasRelationalDescendant } from '../../analysis/scalar-subqueries.js';
36
58
  import { extractConstraints, createTableInfoFromNode } from '../../analysis/constraint-extractor.js';
37
- import { selectPhysicalNode } from '../access/rule-select-access-path.js';
38
- import { peelToAccessLeaf, rebuildChain, buildProbeRequest } from '../shared/access-leaf.js';
59
+ import { selectPhysicalNode, combineResidualExpressions, effectivePredicateCollation, } from '../access/rule-select-access-path.js';
60
+ import { peelToSeekableAccessLeaf, rebuildChain, buildProbeRequest, } from '../shared/access-leaf.js';
39
61
  import { combineResidual } from './equi-pair-extractor.js';
40
62
  import { indexNestedLoopJoinCost } from '../../cost/index.js';
41
63
  import { validateAccessPlan } from '../../../vtab/best-access-plan.js';
64
+ import { uniqueEnforcementCollations } from '../../../schema/unique-enforcement.js';
65
+ import { collationRefines } from '../../../util/comparison.js';
42
66
  const log = createLogger('optimizer:rule:index-nested-loop');
43
67
  /**
44
- * The inner leaf, when it is an unconstrained every-row walk whose `FilterInfo`
45
- * may be replaced wholesale: a plain full scan, or an ordering-only index walk
46
- * (plan='scan'). A leaf already carrying pushed constraints had its residual
47
- * Filter dropped on the module's promise to enforce them; replacing its
48
- * FilterInfo with our seek would silently drop those predicates
49
- * (backlog/feat-index-nested-loop-over-pushed-constraints). A pushed
50
- * limit / offset is likewise a directive the seek would not honor.
68
+ * Join keys FIRST. Both the module (`claimFirstPerRole` in the store,
69
+ * `findEqualityMatches` in memory) and `selectPhysicalNode` pick the FIRST
70
+ * role-filling constraint per column, so when a pushed predicate and a join key
71
+ * land on the same column (`where b.id = 5` joined `on b.id = s.k`) this order
72
+ * is what makes the correlated seek the one that wins the column — the whole
73
+ * point of the candidate. Either leftover is then re-applied as a Filter.
51
74
  */
52
- function admitLeaf(right) {
53
- const leaf = peelToAccessLeaf(right);
75
+ function offerConstraints(joinKeys, pushed) {
76
+ return { combined: [...joinKeys, ...pushed], joinKeyCount: joinKeys.length };
77
+ }
78
+ /** Dispatch on the peeled leaf's kind; each arm owns its own gates. */
79
+ function admitLeaf(inner) {
80
+ const leaf = peelToSeekableAccessLeaf(inner);
54
81
  if (!leaf) {
55
- log('decline: right does not peel to an access leaf through Alias/Project/Filter');
82
+ log('decline: inner does not peel to an access leaf through Alias/Project/Filter');
56
83
  return null;
57
84
  }
85
+ return leaf instanceof IndexSeekNode ? admitSeekLeaf(leaf) : admitWalkLeaf(leaf);
86
+ }
87
+ /**
88
+ * WALK arm: an unconstrained every-row walk whose `FilterInfo` may be replaced
89
+ * wholesale — a plain full scan, or an ordering-only index walk (plan='scan').
90
+ * A pushed limit / offset is a directive the seek would not honor.
91
+ */
92
+ function admitWalkLeaf(leaf) {
58
93
  const fi = leaf.filterInfo;
59
94
  const isEveryRowWalk = fi.accessPath?.kind === 'fullScan'
60
95
  || (fi.accessPath?.kind === 'index' && fi.accessPath.plan === 'scan');
@@ -69,7 +104,63 @@ function admitLeaf(right) {
69
104
  log('decline: leaf emission order is load-bearing (absorbed a Sort)');
70
105
  return null;
71
106
  }
72
- return leaf;
107
+ return { leaf, pushed: [] };
108
+ }
109
+ /**
110
+ * SEEK arm: a leaf whose `FilterInfo` already enforces the predicates recorded
111
+ * in `pushedConstraints` (their residual Filter was dropped on the module's
112
+ * promise). Admitted when that record is complete and re-offerable; the caller
113
+ * re-offers it with the join key and re-applies whatever the module declines,
114
+ * so no recorded predicate can be lost. Same five gates as
115
+ * `rule-key-set-seek`'s `admitSeekLeaf`, for the same reasons.
116
+ */
117
+ function admitSeekLeaf(leaf) {
118
+ const fi = leaf.filterInfo;
119
+ // Gate 1: a pushed limit / offset is a directive the re-planned seek would not
120
+ // honour, and unlike a predicate it cannot be re-applied by a Filter without
121
+ // changing which rows are dropped.
122
+ if (fi.limit !== undefined || fi.offset !== undefined) {
123
+ log('decline: seek leaf carries a pushed limit/offset');
124
+ return null;
125
+ }
126
+ // Gate 2: a seek whose enforced predicate we cannot describe is a seek we
127
+ // cannot safely re-plan.
128
+ if (!leaf.pushedConstraints || leaf.pushedConstraints.length === 0) {
129
+ log('decline: seek leaf records no pushed constraints');
130
+ return null;
131
+ }
132
+ // Gate 3: the seek's emission order absorbed a Sort; a re-planned seek emits
133
+ // in its own key order instead.
134
+ if (leaf.orderingLoadBearing) {
135
+ log('decline: seek leaf emission order is load-bearing (absorbed a Sort)');
136
+ return null;
137
+ }
138
+ for (const c of leaf.pushedConstraints) {
139
+ // Gate 4: a correlated pushed constraint means this leaf is already somebody
140
+ // else's per-outer-row seek (a lateral seek keyed on an enclosing scope);
141
+ // re-planning it would re-plan their correlation. (The caller's
142
+ // sibling-reference guard already blocks this rule's OWN output.)
143
+ //
144
+ // NOTE: this reads the RECORDED constraint's flag, where rule-key-set-seek's
145
+ // equivalent gate asks whether the leaf SUBTREE is correlated. The two agree
146
+ // only because `stampSeekProvenance` is the sole producer of
147
+ // `pushedConstraints` and records every constraint the seek consumed; a
148
+ // future rule that mints a correlated seek without recording the correlated
149
+ // constraint would slip past this gate. Switch to the subtree test if one
150
+ // ever does.
151
+ if (c.correlated === true) {
152
+ log('decline: seek leaf carries a correlated pushed constraint');
153
+ return null;
154
+ }
155
+ // Gate 5: this rule runs PostOptimization, so an expression re-applied here
156
+ // gets no further pass — a relational subquery inside it would reach emit
157
+ // unphysicalized, and would re-execute per outer row besides.
158
+ if (hasRelationalDescendant(c.sourceExpression)) {
159
+ log('decline: seek leaf pushed predicate contains a relational node');
160
+ return null;
161
+ }
162
+ }
163
+ return { leaf, pushed: leaf.pushedConstraints };
73
164
  }
74
165
  /**
75
166
  * Resolve each equi pair to (leaf column index, outer attribute position) and
@@ -141,17 +232,45 @@ function buildSeekPredicate(leaf, outer, resolved) {
141
232
  return combineResidual(undefined, conjuncts);
142
233
  }
143
234
  /**
144
- * Probe the module twice — once with the synthesized join constraints, once
145
- * with no filters — and admit the seek only when the module's OWN answers say a
146
- * per-row seek beats its scan (`cost` and `rows` both smaller). Comparing
147
- * module currency to module currency keeps the engine's cost units out of the
148
- * module's — the same discipline as rule-key-set-seek's break-even.
235
+ * What the inner leaf costs TODAY, in the module's own currency. A seek leaf
236
+ * already records the module's answer for its own seek — `filterInfo.
237
+ * indexInfoOutput.estimatedCost` / `estimatedRows` are `accessPlan.cost` /
238
+ * `accessPlan.rows` verbatim (`makeIndexFilterInfo` spreads a base seeded with
239
+ * them and never overrides either) — while an unconstrained walk has to be
240
+ * asked. Comparing the combined seek against a bare scan instead would let a
241
+ * plan WORSE than the seek already in place win. Same baseline rule as
242
+ * rule-key-set-seek's `probeModuleCosts`.
243
+ *
244
+ * NOTE: `estimatedRows` is `makeFullScanFilterInfo`'s `accessPlan.rows || 1000`,
245
+ * so a module that answered its own seek with `rows: 0` (and did not claim every
246
+ * filter, which would have folded the access to an EmptyResult instead) or with
247
+ * no estimate at all reads back here as a 1000-row baseline the module never
248
+ * stated — the seek arm would then admit a candidate the cost gate should have
249
+ * declined. Both shipped modules always return a positive estimate for a seek,
250
+ * so this is unreachable today; if a third-party module reports 0 or omits
251
+ * `rows`, carry the module's own answer on the leaf instead of re-deriving it
252
+ * from the FilterInfo.
253
+ */
254
+ function displacedPlan(leaf, ask) {
255
+ if (leaf instanceof IndexSeekNode) {
256
+ const out = leaf.filterInfo.indexInfoOutput;
257
+ return { cost: out.estimatedCost, rows: Number(out.estimatedRows) };
258
+ }
259
+ const scan = ask([]);
260
+ return { cost: scan.cost, rows: scan.rows };
261
+ }
262
+ /**
263
+ * Probe the module with the offered constraints and admit the seek only when
264
+ * the module's OWN answers say it beats the plan being displaced (`cost` no
265
+ * higher, `rows` strictly smaller). Comparing module currency to module
266
+ * currency keeps the engine's cost units out of the module's — the same
267
+ * discipline as rule-key-set-seek's break-even.
149
268
  *
150
269
  * These requests are the ENGINE's, not the user's: a module that answers one
151
270
  * with a plan `validateAccessPlan` rejects gets logged and declined, never
152
271
  * thrown — the user's own query ran fine before this rule probed.
153
272
  */
154
- function probeModule(context, leaf, constraints) {
273
+ function probeModule(context, leaf, offered) {
155
274
  const tableSchema = leaf.tableSchema;
156
275
  const vtabModule = leaf.source.vtabModule;
157
276
  const getBestAccessPlan = vtabModule.getBestAccessPlan;
@@ -166,11 +285,12 @@ function probeModule(context, leaf, constraints) {
166
285
  validateAccessPlan(request, plan);
167
286
  return plan;
168
287
  };
288
+ const { combined, joinKeyCount } = offered;
169
289
  let seekPlan;
170
- let scanPlan;
290
+ let baseline;
171
291
  try {
172
- seekPlan = ask(constraints);
173
- scanPlan = ask([]);
292
+ seekPlan = ask(combined);
293
+ baseline = displacedPlan(leaf, ask);
174
294
  }
175
295
  catch (e) {
176
296
  log('decline: module %s answered a synthesized probe on %s with an invalid plan: %s', tableSchema.vtabModuleName, tableSchema.name, e instanceof Error ? e.message : String(e));
@@ -180,18 +300,32 @@ function probeModule(context, leaf, constraints) {
180
300
  log('decline: module did not claim an index seek');
181
301
  return null;
182
302
  }
183
- // Every seek column must be one of our equality constraints, and every
184
- // constraint the seek consumes must be claimed handled — otherwise the
303
+ // Every seek column must be one of the offered constraints — otherwise the
185
304
  // module answered a different question than the one we will emit.
186
- const constraintCols = new Set(constraints.map(c => c.columnIndex));
187
- if (!seekPlan.seekColumnIndexes.every(col => constraintCols.has(col))) {
188
- log('decline: seek columns extend beyond the join constraints');
305
+ const offeredCols = new Set(combined.map(c => c.columnIndex));
306
+ if (!seekPlan.seekColumnIndexes.every(col => offeredCols.has(col))) {
307
+ log('decline: seek columns extend beyond the offered constraints');
189
308
  return null;
190
309
  }
310
+ // At least one seek column must come from a JOIN KEY. Without this the
311
+ // module can answer a seek leaf with the seek it already had: nothing is
312
+ // correlated, nothing is gained, and the rule would rebuild an identical
313
+ // leaf on every visit. (The rebuilt leaf's provenance is re-checked by
314
+ // identity in `tryIndexNestedLoop` — this is the cheap early exit.)
315
+ const joinKeyCols = new Set(combined.slice(0, joinKeyCount).map(c => c.columnIndex));
316
+ if (!seekPlan.seekColumnIndexes.some(col => joinKeyCols.has(col))) {
317
+ log('decline: the seek does not use the join key');
318
+ return null;
319
+ }
320
+ // Every JOIN-KEY constraint on a seek column must be claimed handled — the
321
+ // module must be promising to seek on the key we will emit. Pushed
322
+ // constraints need no such claim: a handled one is re-promised by the new
323
+ // seek or reattached by selectPhysicalNode, an unhandled one is re-applied
324
+ // by `reapplyDeclinedPushed` — either answer is honoured.
191
325
  const seekCols = new Set(seekPlan.seekColumnIndexes);
192
- for (let i = 0; i < constraints.length; i++) {
193
- if (seekCols.has(constraints[i].columnIndex) && seekPlan.handledFilters[i] !== true) {
194
- log('decline: module claimed a seek column without handling its filter');
326
+ for (let i = 0; i < joinKeyCount; i++) {
327
+ if (seekCols.has(combined[i].columnIndex) && seekPlan.handledFilters[i] !== true) {
328
+ log('decline: module claimed a join-key seek column without handling its filter');
195
329
  return null;
196
330
  }
197
331
  }
@@ -200,67 +334,249 @@ function probeModule(context, leaf, constraints) {
200
334
  log('decline: module attached a residualFilter');
201
335
  return null;
202
336
  }
203
- // The module's own statement that a seek beats a scan on this table.
204
- if (seekPlan.rows === undefined || scanPlan.rows === undefined
205
- || !(seekPlan.cost < scanPlan.cost) || !(seekPlan.rows < scanPlan.rows)) {
206
- log('decline: module costs do not favor the seek (seek %s/%s vs scan %s/%s)', seekPlan.cost, seekPlan.rows, scanPlan.cost, scanPlan.rows);
337
+ // The module's own statement that the combined seek beats what it displaces:
338
+ // no dearer, and strictly fewer rows. Rows is the discriminator on purpose —
339
+ // the memory module prices every single-key equality seek identically (cost
340
+ // keyed to the seek-KEY count, not the rows matched), so a pushed
341
+ // `status = 'x'` seek and the join-key seek that would replace it tie on
342
+ // cost and differ only in rows; a strict cost test would leave the seek arm
343
+ // dead on that module for its headline shape.
344
+ if (seekPlan.rows === undefined || baseline.rows === undefined
345
+ || !(seekPlan.cost <= baseline.cost) || !(seekPlan.rows < baseline.rows)) {
346
+ log('decline: module costs do not favor the seek (seek %s/%s vs displaced %s/%s)', seekPlan.cost, seekPlan.rows, baseline.cost, baseline.rows);
207
347
  return null;
208
348
  }
209
349
  return seekPlan;
210
350
  }
211
351
  /**
212
- * Try to build an index-nested-loop candidate for `node`: the right subtree
213
- * rebuilt with its access leaf replaced by a correlated `IndexSeekNode`, plus
352
+ * Re-apply the pushed constraints the module DECLINED (`handledFilters[i]
353
+ * !== true`) as a Filter directly above the rebuilt leaf. `selectPhysicalNode`
354
+ * re-promises the handled ones it consumed (recording them on the new seek's
355
+ * `pushedConstraints`) and reattaches the handled-but-unconsumed ones itself,
356
+ * but — exactly as for a user predicate, where `rule-grow-retrieve` owns the
357
+ * residual — it never touches a constraint the module did not claim. This is
358
+ * the third place, and with it every offered pushed constraint lands in
359
+ * exactly one of: re-promised, reattached, or re-applied here. Join-key
360
+ * leftovers need nothing: the ON condition retained above the join covers them.
361
+ *
362
+ * Folded into every Filter `selectPhysicalNode` may already have wrapped the
363
+ * seek in — it can stack two (a `COARSER_SAFE` collation residual, then a
364
+ * reattach above it) — so the seek carries one residual Filter rather than a
365
+ * stack. `combineResidualExpressions` de-duplicates by identity, so a declined
366
+ * BETWEEN comes back as its single `BetweenNode`.
367
+ */
368
+ function reapplyDeclinedPushed(rebuilt, seekPlan, offered) {
369
+ const { combined, joinKeyCount } = offered;
370
+ const declined = combined.filter((_c, i) => i >= joinKeyCount && seekPlan.handledFilters[i] !== true);
371
+ if (declined.length === 0)
372
+ return rebuilt;
373
+ // Peel the whole Filter stack, outermost predicate first, so the result is one
374
+ // Filter — the same peel `rebuiltSeek` uses to find the seek underneath.
375
+ let below = rebuilt;
376
+ const existing = [];
377
+ while (below instanceof FilterNode) {
378
+ existing.push(below.predicate);
379
+ below = below.source;
380
+ }
381
+ // Non-empty input ⇒ defined result.
382
+ const predicate = combineResidualExpressions([...existing, ...declined.map(c => c.sourceExpression)]);
383
+ log('re-applying %d module-declined pushed constraint(s) above the seek', declined.length);
384
+ return new FilterNode(below.scope, below, predicate);
385
+ }
386
+ /**
387
+ * Every key the table enforces uniqueness on, each with the collation its
388
+ * columns are enforced under: the primary key (a member's own collation, else
389
+ * its column's — `ddl-generator` reads it the same way), every non-partial
390
+ * UNIQUE constraint (`uniqueEnforcementCollations`: the backing index's
391
+ * per-column COLLATE for an index-derived constraint, else the column's), and
392
+ * every non-partial UNIQUE index (its own per-column collation, else the
393
+ * column's — `appendIndexToTableSchema` normally mirrors these as derived
394
+ * constraints already; listed so a host-authored schema carrying the index
395
+ * alone is not missed). A partial key (`predicate` set) only constrains the
396
+ * rows its WHERE admits and proves nothing about the rest.
397
+ */
398
+ function enforcedKeys(tableSchema) {
399
+ const columnCollation = (idx) => tableSchema.columns[idx]?.collation;
400
+ const keys = [];
401
+ const pk = tableSchema.primaryKeyDefinition;
402
+ if (pk.length > 0) {
403
+ keys.push({
404
+ columns: pk.map(k => k.index),
405
+ collations: pk.map(k => k.collation ?? columnCollation(k.index)),
406
+ });
407
+ }
408
+ for (const uc of tableSchema.uniqueConstraints ?? []) {
409
+ if (uc.predicate !== undefined || uc.columns.length === 0)
410
+ continue;
411
+ keys.push({ columns: uc.columns, collations: uniqueEnforcementCollations(tableSchema, uc) });
412
+ }
413
+ for (const ix of tableSchema.indexes ?? []) {
414
+ if (!ix.unique || ix.predicate !== undefined || ix.columns.length === 0)
415
+ continue;
416
+ keys.push({
417
+ columns: ix.columns.map(c => c.index),
418
+ collations: ix.columns.map(c => c.collation ?? columnCollation(c.index)),
419
+ });
420
+ }
421
+ return keys;
422
+ }
423
+ /**
424
+ * True when an equality's comparison collation is at least as fine as the
425
+ * key's enforcement collation — every pair of rows the equality treats as
426
+ * equal, the key treats as equal too, so uniqueness forbids a second match.
427
+ * {@link collationRefines} is the shared two-name test; a COARSER predicate
428
+ * fails it, so a `NOCASE` join key over a `BINARY` unique column (which admits
429
+ * `'a'` and `'A'`, two distinct stored rows that both match) declines.
430
+ *
431
+ * The source-shape gate is load-bearing, not defensive. `effectivePredicateCollation`
432
+ * answers BINARY for a shape it does not recognize — a safe DECLINE for its own
433
+ * caller (a BINARY-read predicate over a coarser index over-fetches and keeps its
434
+ * residual Filter) but the STRONGEST possible claim here, where BINARY refines
435
+ * everything. Every `op: '='` constraint today is minted by `extractBinaryConstraint`
436
+ * carrying its own `BinaryOpNode` (OR collapse emits only 'IN' / 'OR_RANGE'), so
437
+ * requiring that shape costs nothing now and makes a future `=` producer decline
438
+ * rather than be proved unique on a default.
439
+ */
440
+ function equalityRefinesKey(constraint, keyCollation) {
441
+ if (!(constraint.sourceExpression instanceof BinaryOpNode))
442
+ return false;
443
+ return collationRefines(effectivePredicateCollation(constraint), keyCollation);
444
+ }
445
+ /**
446
+ * An equality pins ONE value per outer row only when its value side is
447
+ * deterministic. A constraint re-applied as a Filter is re-evaluated per inner
448
+ * row, so `b.id = random()` admits a different id on every row and several can
449
+ * pass — nothing upstream gates non-deterministic predicates out of pushdown
450
+ * (`rule-select-access-path` has no such test), so the proof must. A literal
451
+ * has no value expression and trivially qualifies; a correlated column
452
+ * reference is deterministic for the duration of one outer row.
453
+ */
454
+ function pinsOneValue(constraint) {
455
+ const value = constraint.valueExpr;
456
+ if (value === undefined)
457
+ return true;
458
+ if (Array.isArray(value))
459
+ return false; // an IN list never reaches here (op !== '='); be explicit
460
+ return PlanNodeCharacteristics.isDeterministic(value);
461
+ }
462
+ /**
463
+ * PROOF — not an estimate — that the inner rebuilt from `combined` yields at
464
+ * most one row per outer row. `FanOutLookupJoinNode`'s `atMostOne-*` branch
465
+ * modes throw `QuereusError(CONSTRAINT)` at runtime on a second row, so the
466
+ * module's `rows: 1` estimate must not stand in: a module that estimates 1 and
467
+ * returns 2 would turn a working query into an error.
468
+ *
469
+ * Premise: every constraint in `combined` is enforced somewhere inside the
470
+ * rebuilt inner. That is total for the `=` constraints this proof reads, by
471
+ * construction rather than by argument — each one is either module-DECLINED, and
472
+ * `reapplyDeclinedPushed` re-applies every declined pushed constraint as a Filter
473
+ * (join-key leftovers need none: the ON condition the caller keeps above the seek
474
+ * covers them), or module-HANDLED, and a handled constraint is either consumed
475
+ * into the seek's bounds or recovered by `selectPhysicalNode`'s
476
+ * `reattachUnconsumedConstraints` — whose `RECLAIMABLE_OPS` set contains `'='`,
477
+ * so no equality can fall through it. A Filter only removes rows, so a constraint
478
+ * enforced by a filter rather than by the index counts just the same. Then: if
479
+ * some enforced key has EVERY column pinned by an equality whose collation
480
+ * refines the key's ({@link equalityRefinesKey}), any two surviving rows agree
481
+ * on the whole key and uniqueness forbids the second. Ranges, IN and OR_RANGE
482
+ * admit many values and never contribute. Over-fetch is harmless — a
483
+ * `COARSER_SAFE` seek's residual Filter re-applies the exact comparison above
484
+ * it — and the finer-index case (`MISMATCH_UNSAFE`) never reaches here: it
485
+ * degrades to a scan in `selectPhysicalNode` and `rebuiltSeek` declines the
486
+ * candidate first. NULL keys need no case: `col = NULL` matches nothing.
487
+ *
488
+ * Exported for direct unit coverage; it is the correctness core of the batched
489
+ * candidate.
490
+ */
491
+ export function provesAtMostOne(tableSchema, combined) {
492
+ const equalitiesByColumn = new Map();
493
+ for (const c of combined) {
494
+ if (c.op !== '=' || !pinsOneValue(c))
495
+ continue;
496
+ const list = equalitiesByColumn.get(c.columnIndex);
497
+ if (list)
498
+ list.push(c);
499
+ else
500
+ equalitiesByColumn.set(c.columnIndex, [c]);
501
+ }
502
+ if (equalitiesByColumn.size === 0)
503
+ return false;
504
+ // A column pinned by several equalities (a pushed `b.id = 5` beside the join
505
+ // key) is proved by ANY one of them: the surviving rows satisfy all of them,
506
+ // so they are a subset of the rows the refining one admits.
507
+ return enforcedKeys(tableSchema).some(key => key.columns.every((col, i) => (equalitiesByColumn.get(col) ?? []).some(eq => equalityRefinesKey(eq, key.collations[i]))));
508
+ }
509
+ /**
510
+ * The `IndexSeekNode` under `rebuilt`'s collation-residual Filter, or null when
511
+ * `selectPhysicalNode` produced something else. It degrades to a SeqScan +
512
+ * residual on a collation decline (MISMATCH_UNSAFE — the seek would
513
+ * under-fetch) and to an EmptyResultNode on an "impossible" predicate; both
514
+ * mean no index-nested-loop here. An EmptyResultNode in particular must NOT be
515
+ * adopted — it would be sound only if the join key were provably literal
516
+ * NULL, which a dynamic per-outer-row binding never is.
517
+ */
518
+ function rebuiltSeek(rebuilt) {
519
+ let probe = rebuilt;
520
+ while (probe instanceof FilterNode)
521
+ probe = probe.source;
522
+ return probe instanceof IndexSeekNode ? probe : null;
523
+ }
524
+ /**
525
+ * Try to build an index-nested-loop candidate: the `inner` subtree rebuilt with
526
+ * its access leaf replaced by an `IndexSeekNode` correlated to `outer`, plus
214
527
  * its engine-currency cost. Returns null when any gate declines; the caller's
215
528
  * nested-loop / hash / merge competition is then unchanged.
216
529
  *
217
- * `equiPairs` must be non-empty and oriented left=outer / right=inner (as
218
- * `extractEquiPairs` produces for this join). `outerRows` is the caller's
219
- * left-side estimate — the same input hash and merge selection use.
530
+ * `joinType` is the type the REBUILT join will have with `outer` on the left
531
+ * (so a mirrored inner join passes 'inner'). `equiPairs` must be non-empty and
532
+ * oriented left=outer / right=inner — the caller flips them for the mirror.
533
+ * `outerRows` is the outer side's row estimate, the same input hash and merge
534
+ * selection use for that side.
220
535
  */
221
- export function tryIndexNestedLoop(node, equiPairs, outerRows, context) {
536
+ export function tryIndexNestedLoop(joinType, outer, inner, equiPairs, outerRows, context) {
222
537
  // Only the left-driven join types: the nested-loop emitter installs the left
223
538
  // row slot before re-opening the right pipeline (driveFromLeft), which is
224
539
  // what makes the correlated seek keys resolve. `right` / `full` drive from
225
540
  // the right with no left slot installed. `cross` never reaches here (no
226
541
  // condition ⇒ no equi pairs).
227
- const jt = node.joinType;
228
- if (jt !== 'inner' && jt !== 'left' && jt !== 'semi' && jt !== 'anti')
542
+ if (joinType !== 'inner' && joinType !== 'left' && joinType !== 'semi' && joinType !== 'anti')
229
543
  return null;
230
544
  // Purity gate. Determinism is deliberately NOT gated: the nested loop
231
545
  // already re-executes the inner side once per outer row, so replacing a scan
232
546
  // with a seek does not change how often a non-deterministic inner runs.
233
547
  // (rule-key-set-seek gates determinism because it drains its key source
234
548
  // exactly once — a different execution-count contract.)
235
- if (PlanNodeCharacteristics.subtreeHasSideEffects(node.right)) {
236
- log('decline: right side has side effects');
549
+ if (PlanNodeCharacteristics.subtreeHasSideEffects(inner)) {
550
+ log('decline: inner side has side effects');
237
551
  return null;
238
552
  }
239
- const leaf = admitLeaf(node.right);
240
- if (!leaf)
553
+ const admitted = admitLeaf(inner);
554
+ if (!admitted)
241
555
  return null;
242
- const resolved = resolvePairs(leaf, node.left, equiPairs);
556
+ const { leaf, pushed } = admitted;
557
+ const resolved = resolvePairs(leaf, outer, equiPairs);
243
558
  if (!resolved || resolved.length === 0)
244
559
  return null;
245
560
  // Synthesize `leaf.col = outer.col` and run it through the same constraint
246
561
  // extraction a pushed user predicate gets.
247
- const predicate = buildSeekPredicate(leaf, node.left, resolved);
562
+ const predicate = buildSeekPredicate(leaf, outer, resolved);
248
563
  const tableSchema = leaf.tableSchema;
249
564
  const tInfo = createTableInfoFromNode(leaf.source, `${tableSchema.schemaName}.${tableSchema.name}`);
250
565
  const extraction = extractConstraints(predicate, [tInfo]);
251
- const constraints = extraction.constraintsByTable.get(tInfo.relationKey) ?? [];
566
+ const joinKeys = extraction.constraintsByTable.get(tInfo.relationKey) ?? [];
252
567
  // Orientation assertion: every conjunct must have extracted as an equality
253
568
  // on an INNER column with a correlated (per-outer-row) binding, with nothing
254
569
  // left over. Inner-side-first construction makes this hold today; the check
255
570
  // is here so a future extractor change declines loudly instead of seeking on
256
571
  // the wrong column.
257
- if (constraints.length !== resolved.length || extraction.residualPredicate !== undefined
258
- || !constraints.every(c => c.op === '=' && c.correlated === true
572
+ if (joinKeys.length !== resolved.length || extraction.residualPredicate !== undefined
573
+ || !joinKeys.every(c => c.op === '=' && c.correlated === true
259
574
  && tInfo.columnIndexMap.has(c.attributeId))) {
260
575
  log('decline: constraint extraction did not return %d oriented correlated equalities', resolved.length);
261
576
  return null;
262
577
  }
263
- const seekPlan = probeModule(context, leaf, constraints);
578
+ const offered = offerConstraints(joinKeys, pushed);
579
+ const seekPlan = probeModule(context, leaf, offered);
264
580
  if (!seekPlan)
265
581
  return null;
266
582
  // Reuse the full access-path machinery: collation cover (over the real
@@ -268,24 +584,27 @@ export function tryIndexNestedLoop(node, equiPairs, outerRows, context) {
268
584
  // comparison collation is what gets checked), composite seeks, NULL
269
585
  // handling, and reattachUnconsumedConstraints all come from
270
586
  // selectPhysicalNode.
271
- const rebuiltLeaf = selectPhysicalNode(leaf.source, seekPlan, [...constraints]);
272
- // Verify an IndexSeekNode actually came back. selectPhysicalNode degrades to
273
- // a SeqScan + residual on a collation decline (MISMATCH_UNSAFE — the seek
274
- // would under-fetch) and to an EmptyResultNode on an "impossible" predicate;
275
- // both mean no index-nested-loop here. An EmptyResultNode in particular must
276
- // NOT be adopted — it would be sound only if the join key were provably
277
- // literal NULL, which a dynamic per-outer-row binding never is.
278
- let probe = rebuiltLeaf;
279
- while (probe instanceof FilterNode)
280
- probe = probe.source;
281
- if (!(probe instanceof IndexSeekNode)) {
282
- log('decline: selectPhysicalNode did not produce an IndexSeek (got %s)', probe.nodeType);
587
+ const rebuiltLeaf = selectPhysicalNode(leaf.source, seekPlan, [...offered.combined]);
588
+ const seek = rebuiltSeek(rebuiltLeaf);
589
+ if (!seek) {
590
+ log('decline: selectPhysicalNode did not produce an IndexSeek');
591
+ return null;
592
+ }
593
+ // The new seek must really be keyed on the join — its recorded provenance
594
+ // must include a join-key constraint by identity. Otherwise the module
595
+ // re-minted the seek it already had (a pushed equality on the seek column
596
+ // won the per-column pick) and nothing is correlated.
597
+ if (!seek.pushedConstraints?.some(c => joinKeys.includes(c))) {
598
+ log('decline: the rebuilt seek is not keyed on the join');
283
599
  return null;
284
600
  }
285
- const newRight = rebuildChain(node.right, leaf, rebuiltLeaf);
286
- const latencyMs = node.right.physical.expectedLatencyMs ?? 0;
287
- const cost = indexNestedLoopJoinCost(outerRows, seekPlan.rows, latencyMs);
288
- log('candidate: seek %s.%s via %s (%s rows/seek, cost %s for %s outer rows)', tableSchema.name, seekPlan.seekColumnIndexes.map(c => tableSchema.columns[c]?.name).join(','), seekPlan.indexName, seekPlan.rows, cost, outerRows);
289
- return { newRight, cost };
601
+ const newLeaf = reapplyDeclinedPushed(rebuiltLeaf, seekPlan, offered);
602
+ const newInner = rebuildChain(inner, leaf, newLeaf);
603
+ const perSeekLatencyMs = inner.physical.expectedLatencyMs ?? 0;
604
+ const rowsPerSeek = seekPlan.rows;
605
+ const cost = indexNestedLoopJoinCost(outerRows, rowsPerSeek, perSeekLatencyMs);
606
+ const atMostOne = provesAtMostOne(tableSchema, offered.combined);
607
+ log('candidate: seek %s.%s via %s (%s rows/seek, cost %s for %s outer rows; %d pushed constraint(s) re-offered; at-most-one %s)', tableSchema.name, seekPlan.seekColumnIndexes.map(c => tableSchema.columns[c]?.name).join(','), seekPlan.indexName, seekPlan.rows, cost, outerRows, pushed.length, atMostOne ? 'proved' : 'unproved');
608
+ return { newInner, cost, rowsPerSeek, perSeekLatencyMs, atMostOne };
290
609
  }
291
610
  //# sourceMappingURL=index-nested-loop.js.map