Skip to main content

krishiv_plan/optimizer/
constant_folding.rs

1//! Constant-folding and tautology-elimination rule (T3).
2//!
3//! Operates on the plan-layer `Filter { predicate: String }` expressions. The
4//! plan layer does not yet have a structured expression AST, so this rule
5//! parses simple, common patterns out of the predicate string:
6//!
7//! - Arithmetic: `1 + 1` → `2`, `(2 * 3) + 4` → `10`.
8//! - Boolean tautologies: `1 = 1` → `true`, `1 = 0` → `false`.
9//! - Logical simplifications:
10//!   - `TRUE AND x`  → `x`
11//!   - `FALSE AND x` → `FALSE`
12//!   - `TRUE OR x`   → `TRUE`
13//!   - `FALSE OR x`  → `x`
14//!   - `NOT TRUE`    → `FALSE`, `NOT FALSE` → `TRUE`
15//! - Constant-folding nested boolean expressions: `1 = 1 AND col = 1` → `col = 1`,
16//!   `1 = 0 AND col = 1` → `FALSE`.
17//!
18//! The rule is conservative: any expression that does not reduce to a
19//! known-constant form is left unchanged. The point is to elide whole
20//! filter sub-expressions and to surface the predicate strings that
21//! contain obviously-constant operands to downstream passes.
22//!
23//! Limitations (follow-ups):
24//! - No typed comparison. `1 = 1` and `'a' = 'a'` both fold, but
25//!   `1 = '1'` is not modelled — these are left to the executor.
26//! - No `IN`, `BETWEEN`, `LIKE`, or `IS NULL` folding. Spark's
27//!   `ConstantFolding` handles these via the typed expression AST; we
28//!   do not have one yet at the plan layer.
29
30use crate::optimizer::OptimizerRule;
31use crate::{LogicalPlan, NodeOp, PlanNode};
32
33/// Optimizer rule that folds constant sub-expressions inside filter predicates
34/// and removes always-true / always-false filters.
35pub struct ConstantFoldingRule;
36
37impl OptimizerRule for ConstantFoldingRule {
38    fn name(&self) -> &str {
39        "constant-folding"
40    }
41
42    fn apply(&self, plan: &LogicalPlan) -> Option<LogicalPlan> {
43        let mut changed = false;
44        let mut new_nodes: Vec<PlanNode> = Vec::with_capacity(plan.nodes().len());
45        for node in plan.nodes() {
46            let new_op = match node.op() {
47                Some(NodeOp::Filter { predicate }) => {
48                    let folded = fold_predicate(predicate);
49                    if folded.as_deref() != Some(predicate.as_str()) {
50                        changed = true;
51                    }
52                    NodeOp::Filter {
53                        predicate: folded.unwrap_or_else(|| predicate.clone()),
54                    }
55                }
56                Some(other) => other.clone(),
57                None => return None,
58            };
59            let mut new_node = PlanNode::new(node.id(), node.label(), node.kind())
60                .with_op(new_op)
61                .with_inputs(node.inputs().to_vec());
62            let partitioning = node.partitioning().clone();
63            new_node = new_node.with_partitioning(partitioning);
64            if let Some(estimated_rows) = node.estimated_rows() {
65                new_node = new_node.with_estimated_rows(Some(estimated_rows));
66            }
67            if node.broadcast_eligible() {
68                new_node = new_node.with_broadcast_eligible(true);
69            }
70            // T3: preserve the output schema so a no-op rule returns a
71            // plan that compares equal to the input. Without this, the
72            // optimizer would record the rule as "applied" (because
73            // `new_plan != current`) even when nothing changed, masking
74            // subsequent rules.
75            let schema = node.output_schema();
76            if !schema.is_empty() {
77                new_node = new_node.with_output_schema(schema.clone());
78            }
79            new_nodes.push(new_node);
80        }
81        if !changed {
82            return None;
83        }
84        // Preserve the plan's name + kind.
85        let mut new_plan = LogicalPlan::new(plan.name(), plan.kind());
86        for node in &new_nodes {
87            new_plan.add_node(node.clone());
88        }
89        if let Some(parts) = plan.shuffle_partitions() {
90            new_plan = new_plan.with_shuffle_partitions(Some(parts));
91        }
92        Some(new_plan)
93    }
94}
95
96/// Fold `predicate`. Returns `Some(new_predicate)` when the predicate changes
97/// (including the empty string for "always-true", but the rule itself
98/// substitutes `TRUE` so the executor treats it as a no-op), or `None` when
99/// the predicate is already constant-true (so the caller can elide the
100/// predicate entirely).
101fn fold_predicate(predicate: &str) -> Option<String> {
102    let trimmed = predicate.trim();
103    if trimmed.is_empty() {
104        return Some("TRUE".to_string());
105    }
106    let folded = try_fold(trimmed);
107    match folded {
108        FoldResult::True => Some("TRUE".to_string()),
109        FoldResult::False => Some("FALSE".to_string()),
110        FoldResult::Expr(s) if s == trimmed => None,
111        FoldResult::Expr(s) => Some(s),
112        FoldResult::Unknown => None,
113    }
114}
115
116enum FoldResult {
117    /// Always-true.
118    True,
119    /// Always-false.
120    False,
121    /// Folded expression (possibly identical to input).
122    Expr(String),
123    /// Could not fold; leave as-is.
124    Unknown,
125}
126
127/// Attempt to fold a single predicate string. The parser is intentionally
128/// minimal — it only recognises integer arithmetic (`+`, `-`, `*`, `/`),
129/// equality comparisons, boolean connectives, and `NOT`. Any other
130/// token (column reference, function call, `IN`, `BETWEEN`, etc.) makes
131/// the whole predicate `Unknown` and we return it unchanged.
132fn try_fold(input: &str) -> FoldResult {
133    let mut p = Parser::new(input);
134    let result = p.parse_or();
135    if !p.eof() {
136        return FoldResult::Expr(input.to_string());
137    }
138    match result {
139        Ok(FoldValue::Bool(b)) => {
140            if b {
141                FoldResult::True
142            } else {
143                FoldResult::False
144            }
145        }
146        Ok(FoldValue::Int(n)) => FoldResult::Expr(n.to_string()),
147        Ok(FoldValue::Str(s)) => FoldResult::Expr(s),
148        Ok(FoldValue::Column(_)) | Err(()) => FoldResult::Unknown,
149    }
150}
151
152#[derive(Debug, Clone, PartialEq)]
153enum FoldValue {
154    Bool(bool),
155    Int(i64),
156    /// String literal preserved verbatim.
157    Str(String),
158    /// A column reference or any other expression that cannot be evaluated
159    /// at planning time. The string is the textual representation; we keep
160    /// it so the caller can decide whether short-circuit rewrites are safe.
161    Column(String),
162}
163
164struct Parser<'a> {
165    bytes: &'a [u8],
166    pos: usize,
167}
168
169impl<'a> Parser<'a> {
170    fn new(s: &'a str) -> Self {
171        Self {
172            bytes: s.as_bytes(),
173            pos: 0,
174        }
175    }
176
177    fn eof(&self) -> bool {
178        self.pos >= self.bytes.len()
179    }
180
181    fn skip_ws(&mut self) {
182        while self.pos < self.bytes.len()
183            && self
184                .bytes
185                .get(self.pos)
186                .is_some_and(|b| b.is_ascii_whitespace())
187        {
188            self.pos += 1;
189        }
190    }
191
192    fn peek(&self) -> Option<u8> {
193        self.bytes.get(self.pos).copied()
194    }
195
196    fn bump(&mut self) -> Option<u8> {
197        let b = self.bytes.get(self.pos).copied();
198        if b.is_some() {
199            self.pos += 1;
200        }
201        b
202    }
203
204    fn eat(&mut self, c: u8) -> bool {
205        self.skip_ws();
206        if self.peek() == Some(c) {
207            self.pos += 1;
208            true
209        } else {
210            false
211        }
212    }
213
214    fn eat_kw(&mut self, kw: &[u8]) -> bool {
215        self.skip_ws();
216        if self
217            .bytes
218            .get(self.pos..)
219            .is_some_and(|s| s.starts_with(kw))
220        {
221            // Must be followed by whitespace or eof to avoid eating `note` as `not`.
222            let after = self.pos + kw.len();
223            if after == self.bytes.len()
224                || self
225                    .bytes
226                    .get(after)
227                    .is_none_or(|b| b.is_ascii_whitespace())
228            {
229                self.pos = after;
230                return true;
231            }
232        }
233        false
234    }
235
236    fn parse_or(&mut self) -> Result<FoldValue, ()> {
237        let mut left = self.parse_and()?;
238        loop {
239            self.skip_ws();
240            if self.eat_kw(b"OR") {
241                let right = self.parse_and()?;
242                // OR reduction rules:
243                //   `TRUE OR x`  → `TRUE`       (always folds to constant)
244                //   `FALSE OR x` → `x`          (only fold if `x` is a column)
245                //   `x OR TRUE`  → `TRUE`       (always folds to constant)
246                //   `x OR FALSE` → `x`          (only fold if `x` is a column)
247                left = match (left.clone(), right) {
248                    (FoldValue::Bool(true), _) | (_, FoldValue::Bool(true)) => {
249                        FoldValue::Bool(true)
250                    }
251                    (FoldValue::Bool(false), r) => r,
252                    (l, FoldValue::Bool(false)) => l,
253                    // All other combinations of `Bool` and `Column` cannot
254                    // be safely rewritten; leave the predicate unchanged.
255                    (FoldValue::Column(_), _) | (_, FoldValue::Column(_)) => {
256                        return Err(());
257                    }
258                    _ => return Err(()),
259                };
260            } else {
261                break;
262            }
263        }
264        Ok(left)
265    }
266
267    fn parse_and(&mut self) -> Result<FoldValue, ()> {
268        let mut left = self.parse_not()?;
269        loop {
270            self.skip_ws();
271            if self.eat_kw(b"AND") {
272                let right = self.parse_not()?;
273                // AND reduction rules:
274                //   `FALSE AND x` → `FALSE`     (always folds to constant)
275                //   `TRUE AND x`  → `x`        (only fold if `x` is a column)
276                //   `x AND FALSE` → `FALSE`     (always folds to constant)
277                //   `x AND TRUE`  → `x`        (only fold if `x` is a column)
278                left = match (left.clone(), right) {
279                    (FoldValue::Bool(false), _) | (_, FoldValue::Bool(false)) => {
280                        FoldValue::Bool(false)
281                    }
282                    (FoldValue::Bool(true), r) => r,
283                    (l, FoldValue::Bool(true)) => l,
284                    (FoldValue::Column(_), _) | (_, FoldValue::Column(_)) => {
285                        return Err(());
286                    }
287                    _ => return Err(()),
288                };
289            } else {
290                break;
291            }
292        }
293        Ok(left)
294    }
295
296    fn parse_not(&mut self) -> Result<FoldValue, ()> {
297        self.skip_ws();
298        if self.eat_kw(b"NOT") {
299            let v = self.parse_cmp()?;
300            return Ok(match v {
301                FoldValue::Bool(b) => FoldValue::Bool(!b),
302                _ => return Err(()),
303            });
304        }
305        self.parse_cmp()
306    }
307
308    fn parse_cmp(&mut self) -> Result<FoldValue, ()> {
309        let start = self.pos;
310        let left = self.parse_add()?;
311        self.skip_ws();
312        // Order matters: `<=` and `>=` must be tested before `<` / `>`.
313        let op = if self.eat(b'<') {
314            if self.eat(b'=') { "<=" } else { "<" }
315        } else if self.eat(b'>') {
316            if self.eat(b'=') { ">=" } else { ">" }
317        } else if self.eat(b'=') {
318            if self.eat(b'=') { "==" } else { "=" }
319        } else if self.eat(b'!') {
320            if self.eat(b'=') {
321                "!="
322            } else {
323                return Err(());
324            }
325        } else {
326            return Ok(left);
327        };
328        let right = self.parse_add()?;
329        // When one side is non-foldable (e.g. a column reference), we cannot
330        // evaluate the comparison, but AND/OR may still be able to
331        // short-circuit. Surface the whole comparison as a `Column` marker
332        // carrying the textual representation so the AND/OR reducers can
333        // see it as a non-constant operand.
334        if matches!(left, FoldValue::Column(_)) || matches!(right, FoldValue::Column(_)) {
335            let end = self.pos;
336            let text = std::str::from_utf8(self.bytes.get(start..end).ok_or(())?)
337                .map_err(|_| ())?
338                .trim()
339                .to_string();
340            return Ok(FoldValue::Column(text));
341        }
342        self.cmp_op(op, left, right)
343    }
344
345    fn cmp_op(&self, op: &str, left: FoldValue, right: FoldValue) -> Result<FoldValue, ()> {
346        match (left, right) {
347            (FoldValue::Int(a), FoldValue::Int(b)) => {
348                let r = match op {
349                    "=" | "==" => a == b,
350                    "!=" => a != b,
351                    "<" => a < b,
352                    "<=" => a <= b,
353                    ">" => a > b,
354                    ">=" => a >= b,
355                    _ => return Err(()),
356                };
357                Ok(FoldValue::Bool(r))
358            }
359            (FoldValue::Str(a), FoldValue::Str(b)) => match op {
360                "=" | "==" => Ok(FoldValue::Bool(a == b)),
361                "!=" => Ok(FoldValue::Bool(a != b)),
362                _ => Err(()),
363            },
364            _ => Err(()),
365        }
366    }
367
368    fn parse_add(&mut self) -> Result<FoldValue, ()> {
369        let mut left = self.parse_mul()?;
370        loop {
371            self.skip_ws();
372            let op = if self.eat(b'+') {
373                Some(b'+')
374            } else if self.eat(b'-') {
375                Some(b'-')
376            } else {
377                None
378            };
379            let Some(op) = op else { break };
380            let right = self.parse_mul()?;
381            left = match (left, right) {
382                (FoldValue::Int(a), FoldValue::Int(b)) => match op {
383                    b'+' => FoldValue::Int(a.checked_add(b).ok_or(())?),
384                    b'-' => FoldValue::Int(a.checked_sub(b).ok_or(())?),
385                    _ => return Err(()),
386                },
387                _ => return Err(()),
388            };
389        }
390        Ok(left)
391    }
392
393    fn parse_mul(&mut self) -> Result<FoldValue, ()> {
394        let mut left = self.parse_unary()?;
395        loop {
396            self.skip_ws();
397            let op = if self.eat(b'*') {
398                Some(b'*')
399            } else if self.eat(b'/') {
400                Some(b'/')
401            } else {
402                None
403            };
404            let Some(op) = op else { break };
405            let right = self.parse_unary()?;
406            left = match (left, right) {
407                (FoldValue::Int(a), FoldValue::Int(b)) => match op {
408                    b'*' => FoldValue::Int(a.checked_mul(b).ok_or(())?),
409                    b'/' => {
410                        if b == 0 {
411                            return Err(());
412                        }
413                        FoldValue::Int(a.checked_div(b).ok_or(())?)
414                    }
415                    _ => return Err(()),
416                },
417                _ => return Err(()),
418            };
419        }
420        Ok(left)
421    }
422
423    fn parse_unary(&mut self) -> Result<FoldValue, ()> {
424        self.skip_ws();
425        if self.eat(b'-') {
426            let v = self.parse_atom()?;
427            return match v {
428                FoldValue::Int(n) => n.checked_neg().map(FoldValue::Int).ok_or(()),
429                _ => Err(()),
430            };
431        }
432        if self.eat(b'+') {
433            return self.parse_atom();
434        }
435        self.parse_atom()
436    }
437
438    fn parse_atom(&mut self) -> Result<FoldValue, ()> {
439        self.skip_ws();
440        if self.eat(b'(') {
441            let v = self.parse_or()?;
442            self.skip_ws();
443            if !self.eat(b')') {
444                return Err(());
445            }
446            return Ok(v);
447        }
448        // Boolean literal.
449        if self.eat_kw(b"TRUE") {
450            return Ok(FoldValue::Bool(true));
451        }
452        if self.eat_kw(b"FALSE") {
453            return Ok(FoldValue::Bool(false));
454        }
455        // String literal — single quotes.
456        if self.peek() == Some(b'\'') {
457            self.pos += 1;
458            let start = self.pos;
459            while let Some(c) = self.peek() {
460                if c == b'\\' {
461                    self.pos += 1;
462                    if self.bump().is_none() {
463                        return Err(());
464                    }
465                    continue;
466                }
467                if c == b'\'' {
468                    break;
469                }
470                self.pos += 1;
471            }
472            if !self.eat(b'\'') {
473                return Err(());
474            }
475            let s = std::str::from_utf8(
476                self.bytes
477                    .get(start..self.pos.saturating_sub(1))
478                    .ok_or(())?,
479            )
480            .map_err(|_| ())?
481            .to_string();
482            return Ok(FoldValue::Str(s));
483        }
484        // Integer literal.
485        let start = self.pos;
486        while let Some(c) = self.peek() {
487            if c.is_ascii_digit() {
488                self.pos += 1;
489            } else {
490                break;
491            }
492        }
493        if start < self.pos {
494            let s =
495                std::str::from_utf8(self.bytes.get(start..self.pos).ok_or(())?).map_err(|_| ())?;
496            return s.parse::<i64>().map(FoldValue::Int).map_err(|_| ());
497        }
498        // Identifier — could be a column reference or a function call.
499        // We return a `Column(name)` marker so the AND/OR reducers can
500        // make short-circuit decisions (e.g. `FALSE AND <col>` → `FALSE`).
501        // Function calls like `upper(name)` are not handled — the
502        // closing paren would have to follow, which the simple atom
503        // parser does not consume — so they fall through to `Err(())`
504        // and the predicate is left unchanged.
505        let id_start = self.pos;
506        while let Some(c) = self.peek() {
507            if c.is_ascii_alphanumeric() || c == b'_' {
508                self.pos += 1;
509            } else {
510                break;
511            }
512        }
513        if id_start < self.pos {
514            // Reject identifier-followed-by-`(` to avoid misinterpreting
515            // function calls as columns. The strict behavior is to leave
516            // the predicate unchanged for function calls.
517            if self.peek() == Some(b'(') {
518                // Roll back the identifier; this is a function call.
519                self.pos = id_start;
520                return Err(());
521            }
522            let name = std::str::from_utf8(self.bytes.get(id_start..self.pos).ok_or(())?)
523                .map_err(|_| ())?
524                .to_string();
525            return Ok(FoldValue::Column(name));
526        }
527        Err(())
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    fn assert_fold(input: &str, expected: &str) {
536        let result = fold_predicate(input);
537        assert_eq!(
538            result.as_deref(),
539            Some(expected),
540            "fold_predicate({input:?}) expected {expected:?}, got {result:?}"
541        );
542    }
543
544    #[test]
545    fn folds_arithmetic() {
546        assert_fold("1 + 1", "2");
547        assert_fold("(2 * 3) + 4", "10");
548        assert_fold("10 - 3", "7");
549        assert_fold("12 / 4", "3");
550        assert_fold("-(5)", "-5");
551    }
552
553    #[test]
554    fn folds_comparisons() {
555        assert_fold("1 = 1", "TRUE");
556        assert_fold("1 = 0", "FALSE");
557        assert_fold("2 > 1", "TRUE");
558        assert_fold("2 < 1", "FALSE");
559    }
560
561    #[test]
562    fn folds_logical_connectives_when_other_side_is_constant() {
563        // `TRUE AND col = 1` is logically `col = 1`, but the rule cannot
564        // rewrite an expression that still has non-constant operands. The
565        // fold result is therefore `None` (left unchanged).
566        assert_eq!(fold_predicate("TRUE AND col = 1"), None);
567        // `FALSE AND x` is always false regardless of x — fold succeeds.
568        assert_fold("FALSE AND col = 1", "FALSE");
569        // `TRUE OR x` is always true.
570        assert_fold("TRUE OR col = 1", "TRUE");
571        // `FALSE OR x` is logically `x`, but we cannot rewrite the
572        // expression (column ref preserved).
573        assert_eq!(fold_predicate("FALSE OR col = 1"), None);
574        assert_fold("NOT TRUE", "FALSE");
575        assert_fold("NOT FALSE", "TRUE");
576    }
577
578    #[test]
579    fn leaves_already_folded_expressions_alone() {
580        let r = fold_predicate("1 + 1");
581        assert_eq!(r.as_deref(), Some("2"));
582    }
583
584    #[test]
585    fn leaves_column_references_alone() {
586        assert_eq!(fold_predicate("col = 1"), None);
587        assert_eq!(fold_predicate("a + b"), None);
588    }
589
590    #[test]
591    fn folds_nested_boolean_when_reduces_to_constant() {
592        // `1 = 0 AND col = 1` reduces to `FALSE` because the LHS is constant
593        // `false`; the rule can rewrite the entire expression.
594        assert_fold("1 = 0 AND col = 1", "FALSE");
595        // `1 = 1 AND col = 1` would logically be `col = 1`, but the rule
596        // cannot rewrite an expression that has non-constant operands
597        // (no expression-AST rewrite is available), so the predicate is
598        // left unchanged.
599        assert_eq!(fold_predicate("1 = 1 AND col = 1"), None);
600    }
601}