spg_engine/eval/compiled.rs
1//! Compiled expressions — PG's ExprState idea (cut 30, extracted
2//! from `eval.rs`; v7.32 perf knife D / architecture v2 P1).
3//!
4//! Walk the tree ONCE per query, pre-resolve column positions and
5//! collation-fold decisions (both row-invariant), emit a flat
6//! post-order step program; per-row evaluation is a linear loop —
7//! no tree dispatch, no name resolution, no collation lookups.
8//! Anything the compiler doesn't model becomes a `Step::Subtree`
9//! that calls the interpreter for that node, so values AND error
10//! behaviour stay bit-for-bit with `eval_expr` (invariant I3).
11
12use alloc::format;
13use alloc::vec::Vec;
14
15use spg_sql::ast::{BinOp, ColumnName, Expr, Literal, UnOp};
16use spg_storage::{Row, Value};
17
18use super::{
19 EvalContext, EvalError, apply_binary, apply_unary, column_collation, composite_eq, eval_expr,
20 like_match_inner, literal_to_value,
21};
22
23pub(crate) enum Step {
24 /// Pre-resolved column read (position into the row).
25 Column(usize),
26 /// Pre-converted literal.
27 Lit(Value<'static>),
28 /// Pops rhs then lhs, pushes the op result. Eager both-sides
29 /// evaluation — same as the interpreter (no short-circuit).
30 Binary(BinOp),
31 /// Comparison whose operands referenced a CaseInsensitive
32 /// column: ASCII-fold Text operands first (decided at compile
33 /// time; the interpreter re-decides per row).
34 BinaryCi(BinOp),
35 Unary(UnOp),
36 IsNull {
37 negated: bool,
38 },
39 /// v7.32 (architecture v2, P1) — `needle [NOT] IN (literals…)`.
40 /// The membership SET is a COMPILE PRODUCT, not a runtime cache:
41 /// it lives in the step, so there is no "forgot to pass the
42 /// memo" failure mode (the round-25 18.7 s accident is now
43 /// unconstructable — see v7.32-executor-architecture-design.md
44 /// invariant I2). The needle is the preceding sub-program; this
45 /// step pops it. `fallback` is the whole InList node, used only
46 /// when the runtime needle family doesn't match the set
47 /// (e.g. Float needle vs Int set) — same escape the interpreter
48 /// takes, evaluated cold.
49 InSet {
50 set: crate::memoize::InListSet,
51 has_null: bool,
52 negated: bool,
53 fallback: Expr,
54 },
55 /// v7.32 (P1) — `text [NOT] [I]LIKE '<literal pattern>'`. The
56 /// pattern (and its lowercased form for ILIKE) is compiled once;
57 /// the step pops the text operand.
58 Like {
59 pattern: alloc::vec::Vec<char>,
60 negated: bool,
61 case_insensitive: bool,
62 },
63 /// v7.36 (perf — mailrs Ask 1) — pure scalar function call
64 /// (LENGTH, COALESCE, UPPER, etc.) on already-pushed args.
65 /// Pops `n_args` values, calls `apply_function(name, args, ctx)`,
66 /// pushes the result. Replaces the Subtree fallback for the
67 /// "function over bound columns" shape that aggregate arg paths
68 /// like `SUM(LENGTH(text_body))` and `MAX(COALESCE(col, ''))`
69 /// otherwise force the row-materialise eval path. Only the
70 /// `fully_compilable` whitelist (PURE scalars — no NOW / RANDOM
71 /// / sequence accessors) is emitted; everything else stays on
72 /// `Step::Subtree`.
73 /// `name_lower` is pre-lowercased at compile time so the per-
74 /// row dispatch in `apply_function` skips an allocation on
75 /// every input row.
76 Function {
77 name_lower: alloc::string::String,
78 n_args: usize,
79 },
80 /// v7.36 (perf — mailrs Ask 1 SUM(LENGTH(text_body)) zero-copy)
81 /// — `LENGTH(<column>)` / `CHAR_LENGTH(<column>)` /
82 /// `CHARACTER_LENGTH(<column>)` over a bound column. Reads the
83 /// cell by reference, computes the char length WITHOUT cloning
84 /// the underlying `String` — the 1 KB text bodies in
85 /// `user_storage_usage` otherwise pay 25 k × 1 KB heap allocs
86 /// per query just to push a `Value::Text` onto the stack so the
87 /// next Step pops it and asks `s.len()`.
88 ColumnLength {
89 pos: usize,
90 },
91 /// v7.36 — `OCTET_LENGTH(<column>)` — byte count, regardless of
92 /// encoding. Even simpler than `ColumnLength` (no ASCII probe).
93 ColumnOctetLength {
94 pos: usize,
95 },
96 /// v7.36 — `CAST(<expr> AS <ty>)` over an already-pushed value.
97 /// Pure / context-free conversion goes through the same
98 /// `cast_value` dispatcher the interpreter uses.
99 Cast {
100 target: spg_sql::ast::CastTarget,
101 },
102 /// v7.37.5-A2b — `CASE [operand] WHEN x THEN y … ELSE z END`.
103 /// Each `(when, then)` branch and the optional `else` is a
104 /// pre-compiled sub-program; the executor short-circuits on the
105 /// first matching WHEN. Compiles only when **every** sub-program
106 /// is itself `fully_compilable` (so the Case never falls back to
107 /// a Subtree that would force a row materialise — profile-guided
108 /// fix for Track A `COUNT(DISTINCT CASE WHEN ...)` aggregates).
109 /// Searched form has `operand=None` and treats each WHEN as a
110 /// Bool predicate; simple form has `operand=Some(prog)` and
111 /// compares the operand value with each WHEN via `BinOp::Eq`.
112 Case {
113 operand: Option<CompiledExpr>,
114 branches: alloc::vec::Vec<(CompiledExpr, CompiledExpr)>,
115 else_branch: Option<CompiledExpr>,
116 },
117 /// Fallback: interpret this subtree with eval_expr.
118 Subtree(Expr),
119}
120
121pub(crate) struct CompiledExpr {
122 steps: Vec<Step>,
123}
124
125impl CompiledExpr {
126 /// v7.36 (perf — mailrs Phase 1, user_storage_usage hot loop) —
127 /// shape inspector for the aggregate's tight inner. Returns
128 /// `Some(pos)` iff this compiled expression is exactly the
129 /// single step `ColumnLength { pos }` — i.e. `LENGTH(<column>)`
130 /// on a bound text column with no surrounding work.
131 pub(crate) fn as_single_column_length(&self) -> Option<usize> {
132 if self.steps.len() == 1
133 && let Step::ColumnLength { pos } = &self.steps[0]
134 {
135 Some(*pos)
136 } else {
137 None
138 }
139 }
140}
141
142/// Column-position resolution at compile time. Mirrors the happy
143/// layers of `resolve_column`; ANY case that would reach an error
144/// path, an ambiguity, or a miss returns None so the node falls
145/// back to the interpreter (identical runtime error / NULL
146/// semantics).
147fn compile_column_pos(c: &ColumnName, ctx: &EvalContext<'_>) -> Option<usize> {
148 if let Some(q) = &c.qualifier {
149 if let Some(pos) = ctx
150 .columns
151 .iter()
152 .position(|s| composite_eq(&s.name, q, &c.name))
153 {
154 return Some(pos);
155 }
156 // resolve_column's error layers live behind this point:
157 // composites under the qualifier exist (ColumnNotFound) or
158 // the qualifier is unknown (UnknownQualifier) — interpret.
159 let prefix_exists = ctx.columns.iter().any(|s| {
160 s.name.starts_with(q.as_str()) && s.name.as_bytes().get(q.len()) == Some(&b'.')
161 });
162 if prefix_exists {
163 return None;
164 }
165 match ctx.table_alias {
166 // Alias-accepted single-table reference: fall through
167 // to the bare layers (the inner-subquery hot shape).
168 Some(a) if a == q => {}
169 _ => return None,
170 }
171 }
172 if let Some(pos) = ctx.columns.iter().position(|s| s.name == c.name) {
173 return Some(pos);
174 }
175 let mut matches = ctx.columns.iter().enumerate().filter(|(_, s)| {
176 s.name.len() > c.name.len()
177 && s.name.ends_with(c.name.as_str())
178 && s.name.as_bytes()[s.name.len() - c.name.len() - 1] == b'.'
179 });
180 let first = matches.next();
181 if matches.next().is_some() {
182 return None; // ambiguous — interpreter owns the error text
183 }
184 first.map(|(i, _)| i)
185}
186
187fn compile_into(e: &Expr, ctx: &EvalContext<'_>, steps: &mut Vec<Step>) {
188 match e {
189 Expr::Literal(l) => steps.push(Step::Lit(literal_to_value(l))),
190 Expr::Column(c) => match compile_column_pos(c, ctx) {
191 Some(pos) => steps.push(Step::Column(pos)),
192 None => steps.push(Step::Subtree(e.clone())),
193 },
194 Expr::Binary { lhs, op, rhs } => {
195 compile_into(lhs, ctx, steps);
196 compile_into(rhs, ctx, steps);
197 let cmp = matches!(
198 op,
199 BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq
200 );
201 let ci = cmp
202 && (matches!(
203 column_collation(lhs, ctx),
204 Some(spg_storage::Collation::CaseInsensitive)
205 ) || matches!(
206 column_collation(rhs, ctx),
207 Some(spg_storage::Collation::CaseInsensitive)
208 ));
209 steps.push(if ci {
210 Step::BinaryCi(*op)
211 } else {
212 Step::Binary(*op)
213 });
214 }
215 Expr::Unary { op, expr } => {
216 compile_into(expr, ctx, steps);
217 steps.push(Step::Unary(*op));
218 }
219 Expr::IsNull { expr, negated } => {
220 compile_into(expr, ctx, steps);
221 steps.push(Step::IsNull { negated: *negated });
222 }
223 Expr::InList {
224 expr,
225 list,
226 negated,
227 } => {
228 // I2: the set is built at compile time. The gate
229 // (`fully_compilable`) guarantees we only reach here
230 // when the list builds a set and the needle compiles —
231 // but keep the Subtree fallback for defence in depth.
232 match crate::build_in_list_set(list) {
233 Some(entry) if fully_compilable(expr) => {
234 compile_into(expr, ctx, steps);
235 steps.push(Step::InSet {
236 set: entry.set,
237 has_null: entry.has_null,
238 negated: *negated,
239 fallback: e.clone(),
240 });
241 }
242 _ => steps.push(Step::Subtree(e.clone())),
243 }
244 }
245 Expr::Like {
246 expr,
247 pattern,
248 negated,
249 case_insensitive,
250 } => match literal_text_pattern(pattern) {
251 Some(pat) if fully_compilable(expr) => {
252 // v7.36 (perf — mailrs Phase 1, get_contacts hot
253 // inner) — trivial all-`%` pattern (`%`, `%%`, …)
254 // matches every non-NULL text. Collapse the LIKE
255 // into a `lhs IS NOT NULL` check: emit the operand
256 // then `IsNull { negated: !*negated }`. For ILIKE
257 // `%%` on 25 k rows the per-row `like_match_inner`
258 // → 2-char walk (~30 ns each) becomes a tag check
259 // (~3 ns); the operand still gets evaluated for the
260 // NULL semantics that SQL `LIKE` requires.
261 if !pat.is_empty() && pat.chars().all(|c| c == '%') {
262 compile_into(expr, ctx, steps);
263 steps.push(Step::IsNull { negated: !*negated });
264 return;
265 }
266 compile_into(expr, ctx, steps);
267 let chars: alloc::vec::Vec<char> = if *case_insensitive {
268 pat.to_lowercase().chars().collect()
269 } else {
270 pat.chars().collect()
271 };
272 steps.push(Step::Like {
273 pattern: chars,
274 negated: *negated,
275 case_insensitive: *case_insensitive,
276 });
277 }
278 _ => steps.push(Step::Subtree(e.clone())),
279 },
280 // v7.36 — PURE scalar function call: emit args then a
281 // single Function step that pops them. `fully_compilable`
282 // gates the whitelist + recurses into args, so this branch
283 // only fires when the entire subtree is compilable.
284 Expr::FunctionCall { name, args } if is_pure_scalar_function(name) => {
285 // v7.36 — specialise `LENGTH(<column>)` /
286 // `OCTET_LENGTH(<column>)` so the column's `Value::Text`
287 // isn't cloned just to read its length. The general
288 // `Step::Function` path goes through `apply_function`,
289 // which can't borrow off the stack — it copies.
290 let lower = name.to_ascii_lowercase();
291 if args.len() == 1 {
292 if let Expr::Column(c) = &args[0]
293 && let Some(pos) = compile_column_pos(c, ctx)
294 {
295 match lower.as_str() {
296 "length" | "char_length" | "character_length" => {
297 steps.push(Step::ColumnLength { pos });
298 return;
299 }
300 "octet_length" => {
301 steps.push(Step::ColumnOctetLength { pos });
302 return;
303 }
304 _ => {}
305 }
306 }
307 }
308 for a in args {
309 compile_into(a, ctx, steps);
310 }
311 steps.push(Step::Function {
312 name_lower: lower,
313 n_args: args.len(),
314 });
315 }
316 Expr::Cast { expr, target } => {
317 compile_into(expr, ctx, steps);
318 steps.push(Step::Cast {
319 target: target.clone(),
320 });
321 }
322 Expr::Case {
323 operand,
324 branches,
325 else_branch,
326 } => {
327 // Gate by `fully_compilable` at the leaf: if any sub-expr
328 // can't compile natively, the whole Case stays Subtree so
329 // a single Case never escapes to a row-materialise eval.
330 let all_ok = operand.as_deref().is_none_or(fully_compilable)
331 && branches
332 .iter()
333 .all(|(w, t)| fully_compilable(w) && fully_compilable(t))
334 && else_branch.as_deref().is_none_or(fully_compilable);
335 if !all_ok {
336 steps.push(Step::Subtree(e.clone()));
337 return;
338 }
339 let op_c = operand.as_deref().map(|o| compile_expr(o, ctx));
340 let branches_c: alloc::vec::Vec<(CompiledExpr, CompiledExpr)> = branches
341 .iter()
342 .map(|(w, t)| (compile_expr(w, ctx), compile_expr(t, ctx)))
343 .collect();
344 let else_c = else_branch.as_deref().map(|el| compile_expr(el, ctx));
345 steps.push(Step::Case {
346 operand: op_c,
347 branches: branches_c,
348 else_branch: else_c,
349 });
350 }
351 other => steps.push(Step::Subtree(other.clone())),
352 }
353}
354
355/// Literal text pattern behind a LIKE/ILIKE, if any.
356fn literal_text_pattern(pattern: &Expr) -> Option<&str> {
357 match pattern {
358 Expr::Literal(Literal::String(s)) => Some(s.as_str()),
359 _ => None,
360 }
361}
362
363/// True when the whole tree consists of nodes the compiler models
364/// natively. Mixed trees stay on the interpreted path: a Subtree
365/// fallback would run WITHOUT the per-query MemoizeCache, and
366/// memo-dependent nodes (InList set fast path — round-25) rebuild
367/// per row there. Measured: compiling a search WHERE with an
368/// InList subtree regressed 634 ms → 18.7 s.
369pub(crate) fn fully_compilable(e: &Expr) -> bool {
370 match e {
371 Expr::Literal(_) | Expr::Column(_) => true,
372 Expr::Binary { lhs, rhs, .. } => fully_compilable(lhs) && fully_compilable(rhs),
373 Expr::Unary { expr, .. } | Expr::IsNull { expr, .. } => fully_compilable(expr),
374 // I2: an InList is compilable ONLY when it becomes a real
375 // InSet (all-literal list + compilable needle). A
376 // non-set-able InList must keep the whole tree off the
377 // compiled path so it never degrades to a memo-less,
378 // O(list) per-row Subtree (the round-25 18.7 s trap).
379 Expr::InList { expr, list, .. } => {
380 fully_compilable(expr) && crate::build_in_list_set(list).is_some()
381 }
382 Expr::Like { expr, pattern, .. } => {
383 fully_compilable(expr) && literal_text_pattern(pattern).is_some()
384 }
385 // v7.36 (perf — mailrs Ask 1) — PURE scalar functions over
386 // compilable args go to `Step::Function`. The whitelist
387 // covers the high-traffic / non-volatile cases; anything
388 // outside (NOW, RANDOM, sequence accessors, EXTRACT-with-
389 // context-dependent fields, etc.) stays on Subtree where
390 // the interpreter has the full ctx.
391 Expr::FunctionCall { name, args } => {
392 is_pure_scalar_function(name) && args.iter().all(fully_compilable)
393 }
394 // v7.36 — CAST over a compilable expression. `cast_value`
395 // is pure / context-free for the scalar targets we care
396 // about (text, ints, floats, bool, dates).
397 Expr::Cast { expr, .. } => fully_compilable(expr),
398 // v7.37.5-A2b — `CASE [operand] WHEN x THEN y … ELSE z END`
399 // when every sub-expression is itself fully-compilable. Hot
400 // shape: Track A's 14 aggregates over
401 // `COUNT(DISTINCT CASE WHEN m.message_id != '' THEN
402 // m.message_id
403 // ELSE CAST(m.id AS TEXT) END)` — without
404 // this, every Case fell to `arg_compiled = None`, forced
405 // `needs_mat = true` per-row, and triggered a full combined-
406 // row `Vec<Value>` clone for the eval path.
407 Expr::Case {
408 operand,
409 branches,
410 else_branch,
411 } => {
412 operand.as_deref().is_none_or(fully_compilable)
413 && branches
414 .iter()
415 .all(|(w, t)| fully_compilable(w) && fully_compilable(t))
416 && else_branch.as_deref().is_none_or(fully_compilable)
417 }
418 _ => false,
419 }
420}
421
422/// v7.36 — PURE scalar function whitelist for `Step::Function`.
423/// "Pure" means: deterministic, context-independent, no side
424/// effects. Aggregate names (sum / count / max / …) are filtered
425/// upstream by the caller — they never reach the compiler. NOW /
426/// RANDOM / sequence accessors are excluded because they need the
427/// `EvalContext`'s clock / sequence resolver and aren't
428/// deterministic. EXTRACT is excluded because the field kind is
429/// parsed off the Expr tree, not an arg.
430fn is_pure_scalar_function(name: &str) -> bool {
431 matches!(
432 name.to_ascii_lowercase().as_str(),
433 // string length + slicing
434 "length"
435 | "char_length"
436 | "character_length"
437 | "octet_length"
438 | "upper"
439 | "lower"
440 | "trim"
441 | "ltrim"
442 | "rtrim"
443 | "btrim"
444 | "left"
445 | "right"
446 | "substring"
447 | "substr"
448 | "replace"
449 | "position"
450 | "strpos"
451 | "concat"
452 | "concat_ws"
453 | "reverse"
454 | "repeat"
455 | "lpad"
456 | "rpad"
457 | "split_part"
458 // null/conditional
459 | "coalesce"
460 | "nullif"
461 | "greatest"
462 | "least"
463 | "ifnull"
464 | "isnull"
465 | "nvl"
466 // numeric
467 | "abs"
468 | "ceil"
469 | "ceiling"
470 | "floor"
471 | "round"
472 | "trunc"
473 | "sqrt"
474 | "power"
475 | "pow"
476 | "mod"
477 | "sign"
478 | "log"
479 | "log10"
480 | "exp"
481 | "ln"
482 // boolean / cast helpers
483 | "cast"
484 )
485}
486
487pub(crate) fn compile_expr(e: &Expr, ctx: &EvalContext<'_>) -> CompiledExpr {
488 let mut steps = Vec::new();
489 compile_into(e, ctx, &mut steps);
490 CompiledExpr { steps }
491}
492
493/// Run a compiled program. `stack` is caller-owned scratch
494/// (cleared here) so tight row loops never touch the allocator
495/// for the machine itself.
496pub(crate) fn eval_compiled(
497 c: &CompiledExpr,
498 row: &Row<'static>,
499 ctx: &EvalContext<'_>,
500 _stack: &mut Vec<Value<'static>>,
501) -> Result<Value<'static>, EvalError> {
502 // v7.37.9 T3 S2 — the public eval_compiled keeps its
503 // `Vec<Value<'static>>` API for callers (post-group projection
504 // path at aggregate.rs:702), but internally drops the borrowed
505 // stack into a local one because the borrow lifetime of the
506 // caller's stack can't bridge the row-borrow `'row` constraint
507 // that eval_compiled_ref needs after S2. This callsite fires
508 // ~50 × per query (LIMIT 50 surviving groups), so the per-call
509 // Vec alloc is negligible compared to the per-row hot path.
510 let rowref = crate::join::RowRef::Owned(row);
511 let mut local_stack: Vec<Value<'_>> = Vec::with_capacity(16);
512 let result = eval_compiled_ref(c, &rowref, ctx, &mut local_stack)?;
513 Ok(result.into_owned())
514}
515
516/// v7.32 (P4 borrow channel, increment 2) — the RowRef-borrowing form of
517/// `eval_compiled`. `Step::Column` borrows its cell straight from the
518/// RowRef (a join tuple resolves it via `tuple_value`, never
519/// materialising a combined Row); only the rare Subtree / InSet
520/// cross-family fallback materialises the row once. Bit-for-bit
521/// equivalent to the Owned path — `eval_compiled` above is now a thin
522/// `RowRef::Owned` wrapper, so there is a single interpreter (invariant
523/// I3); a differential test pins the equivalence.
524// v7.37.9 T3 S1 — row-lifetime stack plumbing. Two lifetimes:
525// `'row` = the RowRef's data lifetime; `'val` = stack value lifetime
526// (must outlive function return). Constraint `'row: 'val` allows the
527// step body to push `Value::Text(Cow::Borrowed(row_cell))` (S2+) while
528// the caller's stack stays at whatever lifetime it declared (often
529// `'static` for Vec<Value<'static>>). S1 keeps every step body forcing
530// `.into_owned()` so behaviour is bit-identical; later stages
531// (S2 Column, S3 Lit, S4 Binary, S6 Function, S7 Case) progressively
532// switch to borrowed push to eliminate per-row String allocs.
533pub(crate) fn eval_compiled_ref<'row, 'val>(
534 c: &'val CompiledExpr,
535 row: &'val crate::join::RowRef<'row>,
536 ctx: &EvalContext<'_>,
537 stack: &mut Vec<Value<'val>>,
538) -> Result<Value<'val>, EvalError>
539where
540 'row: 'val,
541{
542 stack.clear();
543 run_compiled_steps(&c.steps, row, ctx, stack)?;
544 Ok(stack.pop().unwrap_or(Value::Null))
545}
546
547/// v7.37.5-A2b — append-mode entry point for nested sub-programs (the
548/// `Step::Case` executor's per-branch evaluations). Does NOT clear the
549/// stack; pushes the program's result on top of whatever was already
550/// there. Caller uses the `mark` to know where to truncate / pop. Kept
551/// out of public surface — only the Case opcode reaches for it.
552fn eval_compiled_ref_into<'row, 'val>(
553 c: &'val CompiledExpr,
554 row: &'val crate::join::RowRef<'row>,
555 ctx: &EvalContext<'_>,
556 stack: &mut Vec<Value<'val>>,
557 _mark: usize,
558) -> Result<(), EvalError>
559where
560 'row: 'val,
561{
562 run_compiled_steps(&c.steps, row, ctx, stack)
563}
564
565#[inline]
566fn run_compiled_steps<'row, 'val>(
567 steps: &'val [Step],
568 row: &'val crate::join::RowRef<'row>,
569 ctx: &EvalContext<'_>,
570 stack: &mut Vec<Value<'val>>,
571) -> Result<(), EvalError>
572where
573 'row: 'val,
574{
575 // v7.37.9 Phase 1A-ext-2 T1 — counter per call into the Step VM
576 // interpreter. Tells us "how many steps does the average compiled
577 // arg run per row" → narrows the attack target (subtree CSE vs
578 // column-ref-push vs multi-spec combine). Read-only.
579 crate::bump_counter!(STEP_VM_CALL_COUNT);
580 crate::bump_counter!(STEP_VM_STEPS_TOTAL, steps.len() as u64);
581 for step in steps {
582 match step {
583 Step::Column(pos) => {
584 crate::bump_counter!(STEP_VM_COLUMN_FIRE);
585 // v7.37.9 T3 S2 — catalog rows hold `Cow::Owned(String)`
586 // for Text-class variants (per `spg-storage/src/lib.rs:539`
587 // — "Persistent / catalog Values use Value<'static> with
588 // Cow::Owned(...)"). Plain `.clone()` would therefore
589 // still trigger `String::clone()` per cell read. Instead
590 // manually wrap the existing storage into a borrowed Cow
591 // pointing at the same bytes — zero-alloc push.
592 let cell: Value<'val> = match row.get(*pos) {
593 Some(spg_storage::Value::Text(s)) => {
594 spg_storage::Value::Text(alloc::borrow::Cow::Borrowed(s.as_ref()))
595 }
596 Some(spg_storage::Value::Bytes(b)) => {
597 spg_storage::Value::Bytes(alloc::borrow::Cow::Borrowed(b.as_ref()))
598 }
599 Some(spg_storage::Value::Json(s)) => {
600 spg_storage::Value::Json(alloc::borrow::Cow::Borrowed(s.as_ref()))
601 }
602 Some(spg_storage::Value::Vector(v)) => {
603 spg_storage::Value::Vector(alloc::borrow::Cow::Borrowed(v.as_ref()))
604 }
605 // Copy-light variants: clone is free (just enum copy).
606 Some(v) => v.clone(),
607 None => Value::Null,
608 };
609 // Classification counter unchanged (still counts cells
610 // that WERE heap-bearing in the baseline).
611 if matches!(
612 &cell,
613 spg_storage::Value::Text(_)
614 | spg_storage::Value::Bytes(_)
615 | spg_storage::Value::Json(_)
616 | spg_storage::Value::Vector(_)
617 ) {
618 crate::bump_counter!(STEP_VM_COLUMN_HEAP_ALLOC);
619 }
620 stack.push(cell);
621 }
622 Step::Lit(v) => {
623 crate::bump_counter!(STEP_VM_LIT_FIRE);
624 if matches!(
625 v,
626 spg_storage::Value::Text(_)
627 | spg_storage::Value::Bytes(_)
628 | spg_storage::Value::Json(_)
629 | spg_storage::Value::Vector(_)
630 ) {
631 crate::bump_counter!(STEP_VM_LIT_HEAP_ALLOC);
632 }
633 // v7.37.9 T3 S3 — borrow literal storage instead of
634 // String::clone'ing it. Step variants own their
635 // literal (`Value<'static>` enum payload), so we can
636 // safely construct a `Cow::Borrowed(&'static …)` view.
637 // Same pattern as S2's Column path.
638 let pushed: Value<'val> = match v {
639 spg_storage::Value::Text(s) => {
640 spg_storage::Value::Text(alloc::borrow::Cow::Borrowed(s.as_ref()))
641 }
642 spg_storage::Value::Bytes(b) => {
643 spg_storage::Value::Bytes(alloc::borrow::Cow::Borrowed(b.as_ref()))
644 }
645 spg_storage::Value::Json(s) => {
646 spg_storage::Value::Json(alloc::borrow::Cow::Borrowed(s.as_ref()))
647 }
648 spg_storage::Value::Vector(vec) => {
649 spg_storage::Value::Vector(alloc::borrow::Cow::Borrowed(vec.as_ref()))
650 }
651 other => other.clone(),
652 };
653 stack.push(pushed);
654 }
655 Step::Binary(op) => {
656 crate::bump_counter!(STEP_VM_BINARY_FIRE);
657 // v7.37.9 T3 S4 — try the by-ref fast path first
658 // (comparison + 3VL ops). For those, operand bytes are
659 // read but never stored in the result; we avoid the
660 // .into_owned() that would clone every Cow::Borrowed
661 // Text/Bytes/Json/Vector pushed by S2/S3. For ops that
662 // build owned results (arithmetic, concat, json get,
663 // etc.) apply_binary_by_ref returns None and we fall
664 // through to the owning path.
665 let n = stack.len();
666 if n >= 2 {
667 if let Some(result) = super::apply_binary_by_ref(
668 *op,
669 &stack[n - 2],
670 &stack[n - 1],
671 )? {
672 stack.truncate(n - 2);
673 stack.push(result);
674 continue;
675 }
676 }
677 let r = stack.pop().unwrap_or(Value::Null).into_owned();
678 let l = stack.pop().unwrap_or(Value::Null).into_owned();
679 stack.push(apply_binary(*op, l, r)?);
680 }
681 Step::BinaryCi(op) => {
682 let fold = |v: Value<'static>| match v {
683 Value::Text(s) => Value::text(s.to_ascii_lowercase()),
684 other => other,
685 };
686 let r = fold(stack.pop().unwrap_or(Value::Null).into_owned());
687 let l = fold(stack.pop().unwrap_or(Value::Null).into_owned());
688 stack.push(apply_binary(*op, l, r)?);
689 }
690 Step::Unary(op) => {
691 let v = stack.pop().unwrap_or(Value::Null).into_owned();
692 stack.push(apply_unary(*op, v)?);
693 }
694 Step::IsNull { negated } => {
695 let v = stack.pop().unwrap_or(Value::Null);
696 let is_null = matches!(v, Value::Null);
697 stack.push(Value::Bool(if *negated { !is_null } else { is_null }));
698 }
699 Step::InSet {
700 set,
701 has_null,
702 negated,
703 fallback,
704 } => {
705 let needle = stack.pop().unwrap_or(Value::Null);
706 let contained = match (&needle, set) {
707 // Non-empty list + NULL needle → NULL (NOT NULL
708 // is still NULL) — matches the interpreter and
709 // eval_with_in_sets.
710 (Value::Null, _) => {
711 stack.push(Value::Null);
712 continue;
713 }
714 (Value::SmallInt(n), crate::memoize::InListSet::Int(s)) => {
715 s.contains(&i64::from(*n))
716 }
717 (Value::Int(n), crate::memoize::InListSet::Int(s)) => {
718 s.contains(&i64::from(*n))
719 }
720 (Value::BigInt(n), crate::memoize::InListSet::Int(s)) => s.contains(n),
721 (Value::Text(t), crate::memoize::InListSet::Text(s)) => s.contains(t.as_ref()),
722 // Cross-family needle: take the interpreter's
723 // exact coercion / error path on the whole node.
724 _ => {
725 stack.push(eval_expr(fallback, &row.as_row(), ctx)?);
726 continue;
727 }
728 };
729 let inner = if contained {
730 Value::Bool(true)
731 } else if *has_null {
732 Value::Null
733 } else {
734 Value::Bool(false)
735 };
736 stack.push(match (negated, inner) {
737 (true, Value::Bool(b)) => Value::Bool(!b),
738 (_, v) => v,
739 });
740 }
741 Step::Like {
742 pattern,
743 negated,
744 case_insensitive,
745 } => {
746 let v = stack.pop().unwrap_or(Value::Null).into_owned();
747 match v {
748 Value::Null => stack.push(Value::Null),
749 Value::Text(t) => {
750 let text: Vec<char> = if *case_insensitive {
751 t.to_lowercase().chars().collect()
752 } else {
753 t.chars().collect()
754 };
755 let m = like_match_inner(&text, 0, pattern, 0);
756 stack.push(Value::Bool(if *negated { !m } else { m }));
757 }
758 other => {
759 return Err(EvalError::TypeMismatch {
760 detail: format!(
761 "LIKE requires text operands, got {:?}",
762 other.data_type()
763 ),
764 });
765 }
766 }
767 }
768 Step::ColumnLength { pos } => {
769 // v7.36 — zero-copy LENGTH on a column. Read the
770 // cell by reference; compute char count without
771 // cloning the underlying `String`. Saves 25 k ×
772 // ~1 KB heap clones on the user_storage_usage shape.
773 let v = row.get(*pos).unwrap_or(&Value::Null);
774 let pushed = match v {
775 Value::Null => Value::Null,
776 Value::Text(s) => {
777 let n = if s.is_ascii() {
778 i32::try_from(s.len()).unwrap_or(i32::MAX)
779 } else {
780 i32::try_from(s.chars().count()).unwrap_or(i32::MAX)
781 };
782 Value::Int(n)
783 }
784 Value::Bytes(b) => Value::Int(i32::try_from(b.len()).unwrap_or(i32::MAX)),
785 other => {
786 return Err(EvalError::TypeMismatch {
787 detail: format!(
788 "length() needs text or bytea, got {:?}",
789 other.data_type()
790 ),
791 });
792 }
793 };
794 stack.push(pushed);
795 }
796 Step::ColumnOctetLength { pos } => {
797 let v = row.get(*pos).unwrap_or(&Value::Null);
798 let pushed = match v {
799 Value::Null => Value::Null,
800 Value::Text(s) => Value::Int(i32::try_from(s.len()).unwrap_or(i32::MAX)),
801 Value::Bytes(b) => Value::Int(i32::try_from(b.len()).unwrap_or(i32::MAX)),
802 other => {
803 return Err(EvalError::TypeMismatch {
804 detail: format!(
805 "octet_length() needs text or bytea, got {:?}",
806 other.data_type()
807 ),
808 });
809 }
810 };
811 stack.push(pushed);
812 }
813 Step::Function { name_lower, n_args } => {
814 crate::bump_counter!(STEP_VM_FUNCTION_FIRE);
815 let start = stack.len().saturating_sub(*n_args);
816 // `apply_function` borrows the trailing `n_args`
817 // values off the stack; we then truncate + push the
818 // result. `name_lower` is pre-lowercased at compile
819 // time, so dispatch skips the per-row
820 // `to_ascii_lowercase()` allocation.
821 // v7.37.9 T3 S6 — apply_function_lower signature relaxed
822 // to `&[Value<'_>]`; pass the borrowed stack slice
823 // directly. Eliminates the Vec materialise + per-arg
824 // String::clone that S1 introduced as a placeholder.
825 let result =
826 super::functions::apply_function_lower(name_lower, &stack[start..], ctx)?;
827 stack.truncate(start);
828 stack.push(result);
829 }
830 Step::Cast { target } => {
831 crate::bump_counter!(STEP_VM_CAST_FIRE);
832 let v = stack.pop().unwrap_or(Value::Null).into_owned();
833 stack.push(super::cast::cast_value(v, target.clone())?);
834 }
835 Step::Case {
836 operand,
837 branches,
838 else_branch,
839 } => {
840 crate::bump_counter!(STEP_VM_CASE_FIRE);
841 // v7.37.5-A2b — short-circuit Case executor. Mirrors
842 // `Expr::Case` interpreter semantics bit-for-bit (each
843 // WHEN evaluates with its own scratch stack; first
844 // match wins; ELSE = NULL when absent). The outer
845 // `stack` is reused (truncated back to its pre-Case
846 // mark after each sub-program); allocator-free per
847 // branch — the prior version allocated a fresh
848 // `Vec<Value>` per sub-program which showed up as
849 // ~3 % `drop_in_place<Vec<Value>>` self time.
850 let mark = stack.len();
851 // v7.37.9 T3 S7 — Case sub-program lifetime threads
852 // through naturally via S1's `'row: 'val`. Operand /
853 // when / matched / else results are pushed by sub-progs
854 // into our same stack; we pop them as `Value<'val>` and
855 // keep them at that lifetime instead of forcing
856 // into_owned. The simple-form operand match (Eq) uses
857 // apply_binary_by_ref to avoid the operand clone +
858 // pop-side into_owned the S1 placeholder was paying.
859 let operand_value: Option<Value<'val>> = if let Some(op) = operand {
860 eval_compiled_ref_into(op, row, ctx, stack, mark)?;
861 Some(stack.pop().unwrap_or(Value::Null))
862 } else {
863 None
864 };
865 stack.truncate(mark);
866 let mut matched_value: Option<Value<'val>> = None;
867 for (when_c, then_c) in branches {
868 eval_compiled_ref_into(when_c, row, ctx, stack, mark)?;
869 let when_v = stack.pop().unwrap_or(Value::Null);
870 stack.truncate(mark);
871 let matched = match &operand_value {
872 None => matches!(when_v, Value::Bool(true)),
873 Some(op_v) => {
874 // Try the by-ref comparison fast path; fall
875 // back to owning apply_binary only if the
876 // by-ref path returns None (non-comparison
877 // op, which Eq never is).
878 let eq_result = match super::apply_binary_by_ref(
879 BinOp::Eq,
880 op_v,
881 &when_v,
882 )? {
883 Some(v) => v,
884 None => apply_binary(
885 BinOp::Eq,
886 op_v.clone().into_owned(),
887 when_v.clone().into_owned(),
888 )?,
889 };
890 matches!(eq_result, Value::Bool(true))
891 }
892 };
893 if matched {
894 eval_compiled_ref_into(then_c, row, ctx, stack, mark)?;
895 matched_value = Some(stack.pop().unwrap_or(Value::Null));
896 stack.truncate(mark);
897 break;
898 }
899 }
900 let v: Value<'val> = match matched_value {
901 Some(v) => v,
902 None => match else_branch {
903 Some(el) => {
904 eval_compiled_ref_into(el, row, ctx, stack, mark)?;
905 let v = stack.pop().unwrap_or(Value::Null);
906 stack.truncate(mark);
907 v
908 }
909 None => Value::Null,
910 },
911 };
912 stack.push(v);
913 }
914 Step::Subtree(e) => stack.push(eval_expr(e, &row.as_row(), ctx)?),
915 }
916 }
917 Ok(())
918}
919
920/// v7.37.9 Phase 1A-ext-2 T1 — Step VM internal step-type counters.
921/// Read-only diagnostic; gates no behaviour. Used by counter_dump.rs
922/// to ground-truth subtree CSE / column-ref-push / multi-spec-combine
923/// attack ROI estimates.
924pub static STEP_VM_CALL_COUNT: core::sync::atomic::AtomicU64 =
925 core::sync::atomic::AtomicU64::new(0);
926pub static STEP_VM_STEPS_TOTAL: core::sync::atomic::AtomicU64 =
927 core::sync::atomic::AtomicU64::new(0);
928pub static STEP_VM_COLUMN_FIRE: core::sync::atomic::AtomicU64 =
929 core::sync::atomic::AtomicU64::new(0);
930pub static STEP_VM_LIT_FIRE: core::sync::atomic::AtomicU64 =
931 core::sync::atomic::AtomicU64::new(0);
932pub static STEP_VM_BINARY_FIRE: core::sync::atomic::AtomicU64 =
933 core::sync::atomic::AtomicU64::new(0);
934pub static STEP_VM_FUNCTION_FIRE: core::sync::atomic::AtomicU64 =
935 core::sync::atomic::AtomicU64::new(0);
936pub static STEP_VM_CAST_FIRE: core::sync::atomic::AtomicU64 =
937 core::sync::atomic::AtomicU64::new(0);
938pub static STEP_VM_CASE_FIRE: core::sync::atomic::AtomicU64 =
939 core::sync::atomic::AtomicU64::new(0);
940
941/// v7.37.9 Round 3 — heap-alloc counters specifically for the T3
942/// structural attack's ROI estimate. Step::Column / Step::Lit hits
943/// pay a String alloc when the cell variant is heap-bearing
944/// (Text/Bytes/Json/Vector). T3 stack-lifetime push-by-borrow
945/// would eliminate these for the bulk of per-row work.
946pub static STEP_VM_COLUMN_HEAP_ALLOC: core::sync::atomic::AtomicU64 =
947 core::sync::atomic::AtomicU64::new(0);
948pub static STEP_VM_LIT_HEAP_ALLOC: core::sync::atomic::AtomicU64 =
949 core::sync::atomic::AtomicU64::new(0);