Skip to main content

xlog_ir/
rir.rs

1//! Relational IR node definitions
2
3use xlog_core::{AggOp, RelId, ScalarType};
4
5/// Join type variants
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum JoinType {
8    /// Standard inner join
9    Inner,
10    /// Left outer join
11    LeftOuter,
12    /// Semi join (exists check)
13    Semi,
14    /// Anti join (not exists / negation)
15    Anti,
16}
17
18/// Expression in filter predicates
19#[derive(Debug, Clone, PartialEq)]
20pub enum Expr {
21    /// Column reference by index
22    Column(usize),
23    /// Constant value
24    Const(ConstValue),
25    /// Binary comparison
26    Compare {
27        /// Left-hand side expression.
28        left: Box<Expr>,
29        /// Comparison operator.
30        op: CompareOp,
31        /// Right-hand side expression.
32        right: Box<Expr>,
33    },
34    /// Logical AND
35    And(Vec<Expr>),
36    /// Logical OR
37    Or(Vec<Expr>),
38    /// Logical NOT
39    Not(Box<Expr>),
40
41    // Arithmetic operations
42    /// Addition
43    Add(Box<Expr>, Box<Expr>),
44    /// Subtraction
45    Sub(Box<Expr>, Box<Expr>),
46    /// Multiplication
47    Mul(Box<Expr>, Box<Expr>),
48    /// Division
49    Div(Box<Expr>, Box<Expr>),
50    /// Modulo
51    Mod(Box<Expr>, Box<Expr>),
52
53    // Built-in functions
54    /// Absolute value
55    Abs(Box<Expr>),
56    /// Minimum of two values
57    Min(Box<Expr>, Box<Expr>),
58    /// Maximum of two values
59    Max(Box<Expr>, Box<Expr>),
60    /// Power (base, exponent)
61    Pow(Box<Expr>, Box<Expr>),
62    /// Type cast
63    Cast(Box<Expr>, ScalarType),
64
65    /// Conditional expression: if condition then then_expr else else_expr
66    /// The condition is a boolean comparison expression.
67    /// Used for UDF conditionals like: if X >= 100 then 1 else 2
68    Conditional {
69        /// Boolean condition (should evaluate to bool)
70        condition: Box<Expr>,
71        /// Expression to evaluate when condition is true
72        then_expr: Box<Expr>,
73        /// Expression to evaluate when condition is false
74        else_expr: Box<Expr>,
75    },
76}
77
78/// Projection expression -- either a pass-through column reference or a computed value.
79#[derive(Debug, Clone, PartialEq)]
80pub enum ProjectExpr {
81    /// Pass through column at given index.
82    Column(usize),
83    /// Compute an expression whose result has the given scalar type.
84    Computed(Expr, ScalarType),
85}
86
87/// Per-lookup-input permutation for adaptive variable ordering.
88///
89/// When a non-default leader is chosen, the dispatcher rotates kernel
90/// inputs and may swap the two columns of selected lookup atoms (triangle
91/// only — the 4-cycle has rotational symmetry and never needs col-swap).
92/// `swap_cols == true` means the dispatcher must materialize an owned
93/// 2-col view with cols swapped before calling the layout helper.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct LookupPerm {
96    /// Index into the **promoter's canonical input order**:
97    /// triangle = `[e_xy, e_yz, e_xz]`, 4-cycle =
98    /// `[e_wx, e_xy, e_yz, e_zw]`. `lookup_perms[i]` describes
99    /// kernel slot `i + 1` (slots 1, 2, 3 — the non-leader slots).
100    /// The leader slot 0 is identified by `VariableOrder::leader_idx`
101    /// and is never repeated here.
102    pub input_idx: u8,
103    /// Whether to swap col0 ↔ col1 on this input before the layout
104    /// helper sees it.
105    pub swap_cols: bool,
106}
107
108/// Maximum K supported by the K-clique variable-order plan.
109pub const K_CLIQUE_MAX_K: usize = 8;
110
111/// Maximum edge count for K=8 complete binary-edge clique, C(8, 2).
112pub const K_CLIQUE_MAX_EDGES: usize = 28;
113
114/// Column-order rewrite for one K-clique input edge.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ColumnSwap {
117    /// Edge slot to rewrite after edge permutation.
118    pub edge_slot: u8,
119    /// Whether the two source columns should be swapped.
120    pub swap_cols: bool,
121}
122
123/// Sorted-layout requirements carried by a K-clique plan.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct SortedLayoutSpec {
126    /// Edge slots whose sorted layouts are required by the plan.
127    pub edge_slots: Vec<u8>,
128    /// Per-edge key-column order required by the sorted layout.
129    pub key_columns: Vec<Vec<u8>>,
130}
131
132/// Helper relation split requested by the K-clique plan.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct HelperSplitSpec {
135    /// Stable helper identifier within the plan.
136    pub helper_id: u8,
137    /// Variable whose prefix/fanout is split into the helper.
138    pub variable: u8,
139    /// Edge slots materialized into the helper relation.
140    pub edge_slots: Vec<u8>,
141}
142
143/// Stream group assigned to a K-clique plan.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
145pub struct StreamGroupId(pub u8);
146
147/// Full variable-order plan for K=5..K=8 clique-family WCOJ dispatch.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct KCliqueVariableOrder {
150    /// Clique arity K.
151    pub k: u8,
152    /// Position for each variable id; unused entries are `u8::MAX`.
153    pub variable_positions: [u8; K_CLIQUE_MAX_K],
154    /// Edge-slot permutation; unused entries are `u8::MAX`.
155    pub edge_permutation: [u8; K_CLIQUE_MAX_EDGES],
156    /// Optional column swaps after edge permutation.
157    pub column_swaps: Vec<ColumnSwap>,
158    /// Sorted-layout requirements for runtime layout construction.
159    pub sorted_layout_requirements: SortedLayoutSpec,
160    /// Helper-split requests attached to this plan.
161    pub helper_split_specs: Vec<HelperSplitSpec>,
162    /// Stream group consumed by stream-mux scheduling.
163    pub stream_group: StreamGroupId,
164}
165
166impl KCliqueVariableOrder {
167    /// Creates a K-clique variable-order plan with all seven required fields.
168    pub fn new(
169        k: u8,
170        variable_positions: [u8; K_CLIQUE_MAX_K],
171        edge_permutation: [u8; K_CLIQUE_MAX_EDGES],
172        column_swaps: Vec<ColumnSwap>,
173        sorted_layout_requirements: SortedLayoutSpec,
174        helper_split_specs: Vec<HelperSplitSpec>,
175        stream_group: StreamGroupId,
176    ) -> Self {
177        Self {
178            k,
179            variable_positions,
180            edge_permutation,
181            column_swaps,
182            sorted_layout_requirements,
183            helper_split_specs,
184            stream_group,
185        }
186    }
187}
188
189/// Cost evidence carried with a planned WCOJ-vs-hash route.
190#[derive(Debug, Clone, Copy, PartialEq)]
191pub struct CostPredictionRecord {
192    /// Estimated WCOJ work under the selected plan.
193    pub wcoj_cost: f64,
194    /// Estimated hash-chain work under the captured fallback plan.
195    pub hash_cost: f64,
196}
197
198impl CostPredictionRecord {
199    /// Stable evidence for incomplete stats: hash is the safe default route.
200    pub fn empty() -> Self {
201        Self {
202            wcoj_cost: f64::INFINITY,
203            hash_cost: 0.0,
204        }
205    }
206}
207
208/// Auditable reason for a structured hash route.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum PlannedHashReason {
211    /// Planner had complete stats and predicted hash lower-cost.
212    PlannerPredictsHashWins,
213    /// Planner could not build a complete stats-backed plan.
214    IncompleteStatsSafeDefault,
215}
216
217/// Route chosen for a recognized multiway shape.
218#[derive(Debug, Clone, PartialEq)]
219pub enum MultiwayPlan {
220    /// Execute the WCOJ path with the attached K-clique plan.
221    WcojWithPlan(KCliqueVariableOrder),
222    /// Execute the captured fallback as a planned hash route.
223    PlannedHashRoute {
224        /// Why the recognized shape routes to hash.
225        reason: PlannedHashReason,
226        /// Cost evidence that made the route auditable.
227        planner_evidence: CostPredictionRecord,
228    },
229    /// Generic Free Join route emitted ONLY by the general
230    /// multiway promoter. Provenance contract: `inputs` are the
231    /// fallback body's Scan leaves in left-to-right traversal order,
232    /// so `output_columns` (which carries the fallback projection, as
233    /// on every `MultiWayJoin`) coincides positionally with the
234    /// concatenated-inputs column space — the property the Free Join
235    /// dispatcher's head projection relies on. Dedicated-shape
236    /// promoters reorder `inputs` canonically and must never use this
237    /// variant; the dispatcher declines every other plan value.
238    FreeJoin,
239}
240
241/// Variable-ordering decision attached to a `MultiWayJoin`.
242///
243/// `None` on the parent variant preserves legacy triangle, 4-cycle, and
244/// recursive dispatch behavior bit-identically (default leader, no col-swap,
245/// no kernel projection — `output_columns` carries the binary-fallback
246/// projection as before).
247///
248/// When `Some`, the dispatcher consumes `leader_idx` to rotate the
249/// kernel `inputs`, applies any `lookup_perms` col-swaps, and
250/// post-projects the kernel-direct output buffer through
251/// `kernel_output_cols`. `MultiWayJoin::output_columns` stays untouched
252/// so binary-fallback consumers continue reading it directly.
253#[derive(Debug, Clone, PartialEq)]
254pub struct VariableOrder {
255    /// Selected leader's index in the canonical promoter input order
256    /// (e.g., for triangle: 0=e_xy, 1=e_yz, 2=e_xz). `0` reproduces
257    /// the default leader.
258    pub leader_idx: u8,
259    /// One entry per non-leader lookup input, in dispatcher slot order.
260    pub lookup_perms: Vec<LookupPerm>,
261    /// Permutation applied to the kernel-direct output buffer to
262    /// produce head-ordered columns. For default leader this would be
263    /// identity but the field is omitted (`var_order = None`) — the legacy
264    /// triangle/4-cycle path keeps using `MultiWayJoin::output_columns`
265    /// directly.
266    pub kernel_output_cols: Vec<ProjectExpr>,
267    /// Full K-clique variable-order plan for K=5..K=8. `None`
268    /// preserves the legacy triangle/4-cycle leader-permutation path.
269    pub kclique: Option<KCliqueVariableOrder>,
270}
271
272impl VariableOrder {
273    /// Creates the legacy triangle/4-cycle leader-permutation form.
274    pub fn legacy(
275        leader_idx: u8,
276        lookup_perms: Vec<LookupPerm>,
277        kernel_output_cols: Vec<ProjectExpr>,
278    ) -> Self {
279        Self {
280            leader_idx,
281            lookup_perms,
282            kernel_output_cols,
283            kclique: None,
284        }
285    }
286
287    /// Creates the full K-clique variable-order form.
288    pub fn kclique(kclique: KCliqueVariableOrder) -> Self {
289        Self {
290            leader_idx: 0,
291            lookup_perms: Vec::new(),
292            kernel_output_cols: Vec::new(),
293            kclique: Some(kclique),
294        }
295    }
296}
297
298/// Comparison operators
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum CompareOp {
301    /// Equal (`==`)
302    Eq,
303    /// Not equal (`!=`)
304    Ne,
305    /// Less than (`<`)
306    Lt,
307    /// Less than or equal (`<=`)
308    Le,
309    /// Greater than (`>`)
310    Gt,
311    /// Greater than or equal (`>=`)
312    Ge,
313}
314
315/// Constant values in expressions
316#[derive(Debug, Clone, PartialEq)]
317pub enum ConstValue {
318    /// Unsigned 32-bit integer constant.
319    U32(u32),
320    /// Unsigned 64-bit integer constant.
321    U64(u64),
322    /// Signed 32-bit integer constant.
323    I32(i32),
324    /// Signed 64-bit integer constant.
325    I64(i64),
326    /// 32-bit float constant.
327    F32(f32),
328    /// 64-bit float constant.
329    F64(f64),
330    /// Boolean constant.
331    Bool(bool),
332    /// Interned symbol string constant.
333    Symbol(String),
334}
335
336/// Relational IR node types
337#[derive(Debug, Clone)]
338#[expect(
339    clippy::large_enum_variant,
340    reason = "IR variants are matched pervasively by value; boxing the tensor-join payload would add an allocation and a second representation solely to equalize enum sizes"
341)]
342pub enum RirNode {
343    /// A 0-arity relation containing exactly one empty tuple ({()}).
344    ///
345    /// This is the identity element for joins and the natural seed for rules whose bodies
346    /// contain no positive atoms (e.g. `p() :- not q().`), allowing negation-only rules to
347    /// be lowered as set difference against a unit relation.
348    Unit,
349
350    /// Scan a base relation
351    Scan {
352        /// Relation identifier to scan.
353        rel: RelId,
354    },
355
356    /// Filter rows by predicate
357    Filter {
358        /// Input relation subtree to filter.
359        input: Box<RirNode>,
360        /// Boolean predicate applied to each row.
361        predicate: Expr,
362    },
363
364    /// Project specific columns (pass-through or computed)
365    Project {
366        /// Input relation subtree to project from.
367        input: Box<RirNode>,
368        /// Output projection expressions in result-column order.
369        columns: Vec<ProjectExpr>,
370    },
371
372    /// Join two relations
373    Join {
374        /// Left-hand input relation.
375        left: Box<RirNode>,
376        /// Right-hand input relation.
377        right: Box<RirNode>,
378        /// Column indices from the left input used as join keys.
379        left_keys: Vec<usize>,
380        /// Column indices from the right input used as join keys.
381        right_keys: Vec<usize>,
382        /// Join semantics to apply.
383        join_type: JoinType,
384    },
385
386    /// Production two-atom chain join:
387    /// `head(...) :- left(..., Z, ...), right(..., Z, ...)`.
388    ///
389    /// The executor MAY dispatch this node through a specialized
390    /// physical route. On dispatch decline, it must execute `fallback`,
391    /// the IR-equivalent binary join captured at promotion time.
392    ChainJoin {
393        /// Left relation input. The chain-join promoter emits a Scan.
394        left: Box<RirNode>,
395        /// Right relation input. The chain-join promoter emits a Scan.
396        right: Box<RirNode>,
397        /// Join key column in `left`.
398        left_key: usize,
399        /// Join key column in `right`.
400        right_key: usize,
401        /// Output projection in head-tuple order.
402        output_columns: Vec<ProjectExpr>,
403        /// IR-equivalent binary-join plan for fallback execution.
404        fallback: Box<RirNode>,
405    },
406
407    /// Group by with aggregation
408    GroupBy {
409        /// Input relation subtree to aggregate.
410        input: Box<RirNode>,
411        /// Column indices preserved as grouping keys.
412        key_cols: Vec<usize>,
413        /// (value_column, aggregation_op)
414        aggs: Vec<(usize, AggOp)>,
415    },
416
417    /// Union multiple inputs
418    Union {
419        /// Input subtrees whose rows are concatenated together.
420        inputs: Vec<RirNode>,
421    },
422
423    /// Remove duplicates
424    Distinct {
425        /// Input relation subtree to deduplicate.
426        input: Box<RirNode>,
427        /// Column indices defining tuple identity.
428        key_cols: Vec<usize>,
429    },
430
431    /// Set difference (left - right)
432    Diff {
433        /// Left-hand input relation.
434        left: Box<RirNode>,
435        /// Right-hand input relation whose rows are excluded from the left input.
436        right: Box<RirNode>,
437    },
438
439    /// Fixpoint iteration for recursion
440    Fixpoint {
441        /// SCC identifier
442        scc_id: u32,
443        /// Base case computation
444        base: Box<RirNode>,
445        /// Recursive step computation
446        recursive: Box<RirNode>,
447        /// Relation for delta (new tuples)
448        delta_rel: RelId,
449        /// Relation for full result
450        full_rel: RelId,
451    },
452
453    /// A multi-way conjunctive join that the executor MAY dispatch to a
454    /// specialized physical operator (e.g. GPU WCOJ). When the dispatch
455    /// declines, the executor falls through to `fallback`, which is the
456    /// IR-equivalent binary-join plan captured at promotion time.
457    ///
458    /// **Invariant** (upheld by `xlog-logic::promote::promote_multiway`):
459    /// executing `fallback` produces the same row set as a successful
460    /// specialized dispatch.
461    ///
462    /// The original promoter emitted this for the triangle shape; later
463    /// promoters also use it for 4-cycle and general-arity joins.
464    ///
465    /// # Walker contract
466    ///
467    /// Generic walkers and visitors that handle `MultiWayJoin` MUST be
468    /// shape-agnostic over `inputs`, `slot_vars`, and `output_columns`
469    /// — no walker may assume a fixed arity or a specific
470    /// variable-class layout. Only matchers/promoters whose name
471    /// carries an explicit shape qualifier (e.g.
472    /// `match_multiway_triangle`, `try_promote_triangle`) may lock to
473    /// a specific shape.
474    MultiWayJoin {
475        /// Input scans, in physical-plan slot order. For the original
476        /// triangle promoter, this is exactly `[Scan(rel_xy), Scan(rel_yz),
477        /// Scan(rel_xz)]` for a recognized triangle. Each input MUST be
478        /// `RirNode::Scan { rel }`.
479        inputs: Vec<RirNode>,
480        /// Per-slot, per-column variable-class id. Same id across slots →
481        /// join on that variable. For the canonical triangle this is
482        /// `[[Some(0), Some(1)], [Some(1), Some(2)], [Some(0), Some(2)]]`.
483        /// `None` is reserved for constant-bound or don't-care columns;
484        /// the v1 promoter never emits `None`.
485        slot_vars: Vec<Vec<Option<u32>>>,
486        /// Output projection in head-tuple order, identical to what the
487        /// equivalent `Project { input: Join { ... } }` carries. For the
488        /// triangle: `[Column(0), Column(1), Column(3)]`. The executor
489        /// re-validates this; a malformed or rotated projection is
490        /// treated as ineligible (no dispatch).
491        output_columns: Vec<ProjectExpr>,
492        /// IR-equivalent binary-join plan. Executed verbatim on dispatch
493        /// decline. Captured from the post-optimizer tree by the
494        /// promoter; never synthesized.
495        fallback: Box<RirNode>,
496        /// Structured route for recognized multiway shapes. K-clique
497        /// cost-gated hash routes are positive plans, not promoter
498        /// inability to handle the shape.
499        plan: Option<MultiwayPlan>,
500        /// Optional adaptive variable-ordering decision.
501        ///
502        /// `None` preserves legacy triangle, 4-cycle, and recursive dispatch
503        /// behavior bit-identically: dispatcher uses default leader, no
504        /// col-swap, post-kernel projection is the existing `output_columns`.
505        ///
506        /// `Some(VariableOrder)` instructs the dispatcher to rotate
507        /// kernel inputs to put `leader_idx` at slot 0, apply
508        /// `lookup_perms` col-swaps, and post-project via
509        /// `kernel_output_cols`. `output_columns` is NOT consulted on
510        /// the adaptive variable-ordering path; binary-fallback consumers
511        /// still read it.
512        var_order: Option<VariableOrder>,
513    },
514
515    /// Tensorized ILP super-graph join. A DLPack mask tensor selects which
516    /// (body_i, body_j) → head_k rule combinations are active.
517    TensorMaskedJoin {
518        /// Name of the mask tensor registered in the runtime.
519        mask_name: String,
520        /// Arity of the relation schema participating in the tensorized join.
521        schema_size: usize,
522        /// Left-side join key columns within the body schema.
523        left_keys: Vec<usize>,
524        /// Right-side join key columns within the body schema.
525        right_keys: Vec<usize>,
526        /// Mapping from tensor dimension index → (RelId, relation name).
527        /// Sorted by RelId for deterministic ordering.
528        rel_index: Vec<(RelId, String)>,
529        /// Head relation name for store lookup in the executor.
530        head_rel_name: String,
531        /// Head relation ID for optimizer schema lookup, keyed by RelId.
532        head_rel_id: RelId,
533        /// Maximum active rules to process as a budget cap.
534        max_active_rules: usize,
535        /// Column indices from the join result to project into the head schema.
536        /// Maps head column `i` to join result column `head_projection[i]`.
537        /// Join result columns are: [left_col_0..left_col_n, right_col_0..right_col_m].
538        head_projection: Vec<usize>,
539    },
540}
541
542impl RirNode {
543    /// Check if this node is a leaf (Scan)
544    pub fn is_leaf(&self) -> bool {
545        matches!(self, RirNode::Scan { .. })
546    }
547
548    /// Get all relation IDs referenced in this subtree
549    pub fn referenced_relations(&self) -> Vec<RelId> {
550        let mut rels = Vec::new();
551        self.collect_relations(&mut rels);
552        rels
553    }
554
555    fn collect_relations(&self, rels: &mut Vec<RelId>) {
556        match self {
557            RirNode::Unit => {}
558            RirNode::Scan { rel } => rels.push(*rel),
559            RirNode::Filter { input, .. } | RirNode::Project { input, .. } => {
560                input.collect_relations(rels);
561            }
562            RirNode::Join { left, right, .. }
563            | RirNode::ChainJoin { left, right, .. }
564            | RirNode::Diff { left, right } => {
565                left.collect_relations(rels);
566                right.collect_relations(rels);
567            }
568            RirNode::Union { inputs } => {
569                for input in inputs {
570                    input.collect_relations(rels);
571                }
572            }
573            RirNode::GroupBy { input, .. } | RirNode::Distinct { input, .. } => {
574                input.collect_relations(rels);
575            }
576            RirNode::Fixpoint {
577                base,
578                recursive,
579                delta_rel,
580                full_rel,
581                ..
582            } => {
583                base.collect_relations(rels);
584                recursive.collect_relations(rels);
585                rels.push(*delta_rel);
586                rels.push(*full_rel);
587            }
588            RirNode::TensorMaskedJoin { rel_index, .. } => {
589                for (rel_id, _) in rel_index {
590                    rels.push(*rel_id);
591                }
592            }
593            RirNode::MultiWayJoin { inputs, .. } => {
594                // Recurse into `inputs` only. The `fallback` references
595                // the same set by promoter invariant; walking both would
596                // double-count.
597                for input in inputs {
598                    input.collect_relations(rels);
599                }
600            }
601        }
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use xlog_core::ScalarType;
609
610    #[test]
611    fn test_scan_node() {
612        let node = RirNode::Scan { rel: RelId(1) };
613        assert!(matches!(node, RirNode::Scan { rel: RelId(1) }));
614        assert!(node.is_leaf());
615    }
616
617    #[test]
618    fn test_join_node() {
619        let left = Box::new(RirNode::Scan { rel: RelId(1) });
620        let right = Box::new(RirNode::Scan { rel: RelId(2) });
621        let join = RirNode::Join {
622            left,
623            right,
624            left_keys: vec![0],
625            right_keys: vec![0],
626            join_type: JoinType::Inner,
627        };
628        assert!(matches!(join, RirNode::Join { .. }));
629        let rels = join.referenced_relations();
630        assert!(rels.contains(&RelId(1)));
631        assert!(rels.contains(&RelId(2)));
632    }
633
634    #[test]
635    fn test_fixpoint_node() {
636        let base = Box::new(RirNode::Scan { rel: RelId(1) });
637        let recursive = Box::new(RirNode::Scan { rel: RelId(2) });
638        let fp = RirNode::Fixpoint {
639            scc_id: 0,
640            base,
641            recursive,
642            delta_rel: RelId(3),
643            full_rel: RelId(4),
644        };
645        assert!(matches!(fp, RirNode::Fixpoint { scc_id: 0, .. }));
646    }
647
648    #[test]
649    fn test_anti_join() {
650        let left = Box::new(RirNode::Scan { rel: RelId(1) });
651        let right = Box::new(RirNode::Scan { rel: RelId(2) });
652        let anti = RirNode::Join {
653            left,
654            right,
655            left_keys: vec![0],
656            right_keys: vec![0],
657            join_type: JoinType::Anti,
658        };
659        if let RirNode::Join { join_type, .. } = anti {
660            assert_eq!(join_type, JoinType::Anti);
661        }
662    }
663
664    #[test]
665    fn test_expr_arithmetic() {
666        let expr = Expr::Add(
667            Box::new(Expr::Column(0)),
668            Box::new(Expr::Const(ConstValue::I64(1))),
669        );
670        assert!(matches!(expr, Expr::Add(_, _)));
671    }
672
673    #[test]
674    fn test_project_expr_computed() {
675        let proj = ProjectExpr::Computed(
676            Expr::Add(
677                Box::new(Expr::Column(0)),
678                Box::new(Expr::Const(ConstValue::I64(1))),
679            ),
680            ScalarType::I64,
681        );
682        assert!(matches!(proj, ProjectExpr::Computed(_, _)));
683    }
684}