README

ihb2032/MoonFrame/expr does not have a README file

#
Expr

pub struct Expr {
// private fields
}

A composable column expression — the reified upgrade over the closure-based surfaces (the original row-predicate closure) and pre-materialised Series columns. An Expr is built through the constructor functions (col / lit / lit_* / when), the operator impls, and the methods in expr_ops.mbt, so every tree is well-formed by construction. Building one is total — unknown columns and dtype mismatches surface at evaluation time (in frame), never here.

The type is opaque: it wraps an @ir.ExprNode AST and exposes no variants. Outside this package an expression is a value you build and pass on, or render with to_string — there is no matching on its shape. The AST itself lives in the module-internal internal/ir package, which a downstream module cannot import at all, so no caller can name a node, match one, or hold one: adding a node for a new operator breaks nobody.

The shape is not observable either: there is no == on an expression. One existed, comparing the two trees, and it made how an operator lowers a promise to callers — normalising a tree or merging two node kinds would have changed what compared equal without changing what any expression means. to_string() is what a caller inspects with instead; it renders what it prints, so two literal series differing only in their cells render alike.

The engine reads the shape through the node() accessor; frame and lazy are in-module and match @ir.ExprNode directly.

The wrapped AST carries @ir.ExprNode children, not Expr, so this package bridges the two: a constructor unwraps its child Exprs with .node, and a walk (in explain.mbt) wraps ExprNode children back into Expr. The nodes, in construction-route order:
  • Col / Lit / LitSeries — leaves (col, lit / lit_*, lit_series); a LitSeries embeds a pre-materialised @series.Series;
  • Binary — arithmetic + - * /, comparisons, Kleene & / |;
  • Unary-e, .not(), the null / NaN probes, and .abs() / .floor() / .ceil() / .sign() / .round();
  • Agg.sum() / .mean() / … reductions;
  • Str — the .str_* namespace;
  • Cast.cast(dtype); Alias.with_alias(name);
  • Ternarywhen(c).then(a).otherwise(b);
  • FillNull / FillNan.fill_null(v) / .fill_nan(v), dedicated nodes (not lowered to a Ternary) so the operand appears once and a chained coalesce stays linear;
  • IsIn / IsBetween — the membership / range predicates, likewise dedicated so the operand is evaluated once;
  • Map / MapBatches — the row-wise / batched closure escape hatches (.map_elements() / map_many() / .map_batches()), the function opaque so each is identified by its (label, inputs) (MapBatches also by its returns_scalar flag).
impl Add for Expr
impl BitAnd for Expr
impl BitOr for Expr
impl Div for Expr
impl Mul for Expr
impl Neg for Expr
impl Show for Expr
impl Sub for Expr
impl Debug for Expr

#
Expr::abs

fn Expr::abs(self : Expr) -> Expr

a.abs() — absolute value. Int → Int (Int64::MIN wraps, like Neg); Float → Float (|NaN| = NaN, |-0.0| = 0.0). A null stays null; a non-numeric operand raises TypeMismatch.

#
Expr::add

fn Expr::add(self : Expr, other : Expr) -> Expr

#
Expr::cast

Cast the operand to target at evaluation time, delegating to Series::cast (so the supported dtype pairs — and the Unsupported cases — are exactly the eager ones).

#
Expr::ceil

fn Expr::ceil(self : Expr) -> Expr

a.ceil() — round toward +∞ to an integer value. Float → Float; Int →Int unchanged. ±inf / NaN pass through. A null stays null; a non-numeric operand raises TypeMismatch.

#
Expr::count

fn Expr::count(self : Expr) -> Expr

Count of non-null cells of the operand over the evaluation scope (Series::count semantics).

#
Expr::div

fn Expr::div(self : Expr, other : Expr) -> Expr

#
Expr::eq

fn Expr::eq(self : Expr, other : Expr) -> Expr

a.eq(b) — elementwise equality, producing a Bool column. The whole comparison family is methods, not operators: MoonBit pins the Eq / Compare traits to Bool / Int returns, so they cannot build an Expr — and the postfix method binds tighter than & / | anyway, so a.gt(x) & b.lt(y) needs none of the parentheses Polars' operator comparisons force. A null on either side nulls the output cell.

#
Expr::fill_nan

fn Expr::fill_nan(self : Expr, value : Expr) -> Expr

Replace every NaN cell of self with value, keeping the non-NaN cells (including true nulls) verbatim — Polars' fill_nan. The dual of fill_null: where fill_null replaces missing cells and leaves NaN (a value) alone, fill_nan replaces NaN and leaves nulls alone.

It evaluates exactly like when(self.is_not_nan()).then(self).otherwise(value)named after self (the operand), and a null cell — for which is_not_nan is null — falls through the Kleene ternary to a null result, never to value. The branches unify their dtype like any ternary (Int meets Float by promoting to Float, any other mismatch is a TypeMismatch). Unlike that ternary spelling, the dedicated FillNan node holds (and evaluates) self once, so a chain of fills stays linear in tree size and work. Building the node is total.

#
Expr::fill_null

fn Expr::fill_null(self : Expr, value : Expr) -> Expr

Replace every null cell of self with value, keeping the non-null cells verbatim — Polars' fill_null (the value form), and a coalesce of self over value when value is itself a column. value is any expression: a literal (lit_int(0)), another column (col("fallback")), or a computed tree.

It evaluates exactly like when(self.is_not_null()).then(self).otherwise(value) — the result is named after self (the filled column), never after value, so with_columns([col("x").fill_null(...)]) replaces "x" in place. The branches must unify their dtype the way a ternary's do — Int meets Float by promoting to Float, any other mismatch is a TypeMismatch at evaluation. A non-null NaN is a value (validity 1), so it is kept, not filled; only true nulls are replaced. Unlike that ternary spelling, the dedicated FillNull node holds (and evaluates) self once, so the chained-coalesce idiom (col("a").fill_null(col("b")).fill_null(lit_int(0))) stays linear in tree size and work — the lowering embedded self twice and grew exponentially with chain length. Building the node is total; errors surface when a consuming verb evaluates it.

#
Expr::first

fn Expr::first(self : Expr) -> Expr

First cell of the operand over the evaluation scope, in row order, keeping the operand's dtype. Positional — a null first cell is null, an empty scope is null, and a present NaN passes through verbatim.

#
Expr::floor

fn Expr::floor(self : Expr) -> Expr

a.floor() — round toward −∞ to an integer value. Float → Float; Int →Int unchanged (an integer is its own floor). ±inf / NaN pass through. A null stays null; a non-numeric operand raises TypeMismatch.

#
Expr::floor_div

fn Expr::floor_div(self : Expr, other : Expr) -> Expr

a.floor_div(b) — floor (integer) division, rounding the quotient toward negative infinity (Polars //). Named, not an operator: MoonBit's // is a line comment. Same-dtype Int / Int → Int (-7 // 2 = -4, not the -3 truncation gives); any Float operand promotes the result to Float (floor(a / b)). Int division by zero yields a null cell (integers have no infinity, and a backend integer divide would trap); Float division by zero follows IEEE 754 (±inf / nan) like /. A null on either side nulls the output cell; a non-numeric operand raises TypeMismatch.

#
Expr::ge

fn Expr::ge(self : Expr, other : Expr) -> Expr

a.ge(b) — elementwise >= (Bool column, null-propagating).

#
Expr::gt

fn Expr::gt(self : Expr, other : Expr) -> Expr

a.gt(b) — elementwise > (Bool column, null-propagating).

#
Expr::is_between

fn Expr::is_between(self : Expr, lo : Expr, hi : Expr, closed? :
ClosedInterval
) -> Expr

a.is_between(lo, hi) — a Bool column, true where a falls in the range. closed picks which endpoints count (Polars' closed): Both (the default) is lo <= a <= hi, Left / Right open the other end, and None excludes both. Equivalent to the matching ge / gt and le / lt pair joined by land — it inherits their exact Int/Float ordering, String / Bool ordering, Kleene null propagation, and TypeMismatch on an unorderable pair — but as a dedicated node the operand a is evaluated once.

#
Expr::is_in

fn Expr::is_in(self : Expr, members : Array[
Scalar
]) -> Expr

a.is_in(members) — a Bool column, true where the cell equals one of the literal members. Each member is compared exactly as a.eq(lit(member)) would — the general path is an OR of eq over the set, and a String / Bool / Int column whose members share its dtype takes an equivalent single-pass membership test instead: Int / Float members compare across types exactly (no 2^53 collision), and a member whose dtype cannot compare with the column raises TypeMismatch. A Null member matches nothing (it has no value to equal), an empty set is false for every present cell, and a null cell yields null.

#
Expr::is_nan

fn Expr::is_nan(self : Expr) -> Expr

Bool column that is true where the operand holds the IEEE NaN value (Polars' is_nan). The operand must be numeric: an Int cell is never NaN (false), a Float cell is tested by Double::is_nan, and a non-numeric operand is a TypeMismatch at evaluation. Unlike is_null, this propagates nulls — a missing cell is neither NaN nor not, so the result cell is null — since NaN is a real value distinct from a missing one. Building the node is total.

#
Expr::is_not_nan

fn Expr::is_not_nan(self : Expr) -> Expr

Bool column that is true where the operand is a non-NaN numeric value — the complement of is_nan on non-null cells, propagating nulls the same way (a missing cell stays null). Same numeric-operand requirement.

#
Expr::is_not_null

fn Expr::is_not_null(self : Expr) -> Expr

Bool column that is true where the operand is non-null — the complement of is_null, equally total.

#
Expr::is_null

fn Expr::is_null(self : Expr) -> Expr

Bool column that is true where the operand is null. Reads validity only, so the result is itself never null (total).

#
Expr::land

fn Expr::land(self : Expr, other : Expr) -> Expr

#
Expr::last

fn Expr::last(self : Expr) -> Expr

Last cell of the operand over the evaluation scope, in row order, keeping the operand's dtype. The positional mirror of first.

#
Expr::le

fn Expr::le(self : Expr, other : Expr) -> Expr

a.le(b) — elementwise <= (Bool column, null-propagating).

#
Expr::lor

fn Expr::lor(self : Expr, other : Expr) -> Expr

#
Expr::lt

fn Expr::lt(self : Expr, other : Expr) -> Expr

a.lt(b) — elementwise < (Bool column, null-propagating).

#
Expr::map_batches

fn Expr::map_batches(self : Expr, label~ : String, returns_scalar? : Bool, f : (
Series
) ->
Series
raise
DataError
) -> Expr

Apply a host closure to self's whole evaluated column at once — the batched escape hatch (Polars' map_batches). Where map_elements hands f one @types.Scalar per row, map_batches hands it the entire @series.Series and takes back a Series, so a vectorised kernel (a cumulative sum, a rank, a rolling window) runs once over the column rather than cell by cell. label names the step in explain / Show; the closure is opaque to introspection, to rendering, and to the optimizer, which treats the node as a barrier because it cannot see through it.

Building the node is total; f runs at evaluation and may raise. The returned series rides the same length contract as lit_series: a frame-tall result passes through, a length-1 result broadcasts, any other length raises LengthMismatch in the consuming verb. The result is named after self, so with_alias renames it, and its backend is canonicalised (an all-valid numeric result lands on Numeric) so collect ≡ execute.

Set returns_scalar when f reduces its input to a length-1 series: it marks the node as a per-group reduction so it is accepted as a custom aggregation inside group_by(...).agg([...]), where f receives each group's rows and must return a length-1 series (a non-length-1 result raises LengthMismatch at collect). Left false (the default), the node is a plain row-wise expression and the optimizer treats it — like map_elements — as a value-and-shape barrier that no filter sinks across.

#
Expr::map_elements

Apply a host closure to each row of self — the single-input escape hatch (Polars' map_elements). Where the operators and methods above cover the documented algebra, map_elements reaches past it: f receives the operand's cell as a @types.Scalar (a null cell as Scalar::Null) and returns the output cell. label names the step in explain / Show output — the function itself is opaque to introspection, to rendering, and to the optimizer, which treats the node as a barrier because it cannot see through the closure.

Building the node is total; f runs at evaluation, once per row, and may raise (the error propagates from the consuming verb). The output column's dtype is that of the first non-null Scalar the closure returns, except that a closure returning both Int and Float cells promotes the column to Float — the engine's Int → Float rule — rather than nulling whichever type came second. An all-null (or empty) result has no such cell, so its dtype falls back to self's own dtype, yielding an all-null (or empty) column of that dtype rather than the Unsupported a bare Null literal raises (Polars' tolerance of a null-returning map). Only the dtype is borrowed; the result's backend follows its own content, as every computed column's does. The result is named after self (its leftmost column), so with_alias renames it. Use it inside with_columns / select / filter like any other expression; the optimizer treats it as a value barrier (it can raise on values), so no filter sinks across it.

#
Expr::max

fn Expr::max(self : Expr) -> Expr

Maximum of the operand over the evaluation scope (Series::max semantics: NaN and nulls are skipped).

#
Expr::mean

fn Expr::mean(self : Expr) -> Expr

Mean of the operand over the evaluation scope (Series::mean semantics: NaN propagates, nulls are skipped).

#
Expr::median

fn Expr::median(self : Expr) -> Expr

Median of the operand over the evaluation scope. NaN and nulls are skipped (the order-statistic counterpart of min / max), an all-missing scope reduces to null, and a non-numeric operand is TypeMismatch. Always a Float (Int widens), so an even count averages its two middles.

#
Expr::min

fn Expr::min(self : Expr) -> Expr

Minimum of the operand over the evaluation scope (Series::min semantics: NaN and nulls are skipped).

#
Expr::modulo

fn Expr::modulo(self : Expr, other : Expr) -> Expr

a.modulo(b) — remainder (Polars %; a named method, % maps to no Expr operator). Same-dtype Int / Int → Int carrying the dividend's sign (-7 % 2 = -1); any Float operand promotes to Float (IEEE remainder). Int modulo by zero yields a null cell (as for floor_div); Float modulo by zero is NaN. A null on either side nulls the output cell; a non-numeric operand raises TypeMismatch.

#
Expr::mul

fn Expr::mul(self : Expr, other : Expr) -> Expr

#
Expr::n_unique

fn Expr::n_unique(self : Expr) -> Expr

Number of distinct non-null values of the operand over the evaluation scope (Series::n_unique semantics: every NaN is one bucket, -0.0 folds into +0.0). Total over every dtype, never null — an Int.

#
Expr::ne

fn Expr::ne(self : Expr, other : Expr) -> Expr

a.ne(b) — elementwise inequality (Bool column, null-propagating).

#
Expr::neg

fn Expr::neg(self : Expr) -> Expr

#
Expr::not

fn Expr::not(self : Expr) -> Expr

Kleene logical negation of a Bool expression (not(null) = null). A method because MoonBit has no overloadable unary ~ / !.

#
Expr::output

fn Expr::output(self : Expr, logger : &Logger) -> Unit

#
Expr::pow

fn Expr::pow(self : Expr, other : Expr) -> Expr

a.pow(b) — exponentiation, always Float (Int operands promote to Double, like /): total for every base / exponent — a negative exponent, a negative or fractional base, and overflow all resolve under IEEE 754 (0.0 ** 0.0 = 1.0, an out-of-range result is ±inf, an invalid one NaN). A null on either side nulls the output cell; a non-numeric operand raises TypeMismatch.

#
Expr::round

fn Expr::round(self : Expr, decimals? : Int) -> Expr

a.round() — round to the nearest integer value, ties to even (banker's rounding: 2.5 and 3.5 both round to 2 and 4, 0.5 → 0), matching Polars' default. Float → Float; Int → Int unchanged (an integer is its own rounding). ±inf / NaN pass through and ±0.0 keeps its sign. A null stays null; a non-numeric operand raises TypeMismatch. decimals (default 0) rounds to that many decimal places — decimals=2 sends 1.005 to 1.0 or 1.01 as binary floating point dictates, the same caveat Polars carries. A negative decimals clamps to 0, and an Int column is the identity at any setting. So is a place finer than the value's own resolution: round(decimals=20) returns 123456.789 unchanged rather than perturbing it by an ulp. (The other rounding modes remain a deferred additive refinement; this is the ties-to-even form.)

#
Expr::sign

fn Expr::sign(self : Expr) -> Expr

a.sign()-1 / 0 / +1 by sign, in the operand's own dtype (Int →Int, Float → Float). Float NaN stays NaN and ±0.0 gives 0.0. A null stays null; a non-numeric operand raises TypeMismatch.

#
Expr::std

fn Expr::std(self : Expr) -> Expr

Sample standard deviation of the operand over the evaluation scope (ddof = 1, Polars' default): NaN propagates through the mean, nulls are skipped, fewer than two non-null cells reduce to null, and a non-numeric operand is TypeMismatch. Always a Float.

#
Expr::str_contains

fn Expr::str_contains(self : Expr, pattern : String, literal? : Bool) -> Expr

A Bool column that is true where the cell contains pattern — Polars' str.contains. literal defaults to true, matching pattern as a plain substring; literal=false reads it as a POSIX regular expression (the core engine's dialect, so character classes are [[:digit:]] / [[:alpha:]], not the PCRE \d / \w, which raise). The default is the opposite of Polars', which is regex-first. A regex is compiled once per evaluation, so an invalid pattern raises InvalidOperation then. Null cells stay null.

#
Expr::str_count_matches

fn Expr::str_count_matches(self : Expr, pattern : String) -> Expr

Count the non-overlapping matches of the POSIX regular expression pattern in each cell — an Int column (Polars' str.count_matches), 0 where the pattern does not match. An all-valid result rides the Numeric fast path, like str_len_chars. See str_contains for the regex dialect and the invalid-pattern error; a null cell stays null and a non-String operand raises TypeMismatch.

#
Expr::str_ends_with

fn Expr::str_ends_with(self : Expr, suffix : String) -> Expr

A Bool column that is true where the cell ends with suffix (Polars str.ends_with). Null cells stay null.

#
Expr::str_extract

fn Expr::str_extract(self : Expr, pattern : String, group? : Int) -> Expr

Extract a substring matched by the POSIX regular expression pattern — a nullable String column (Polars' str.extract). group (default 0, the whole match — Polars defaults to the first capture group 1 instead) selects a capture group; a cell that does not match, or whose chosen group did not participate, yields null. See str_contains for the regex dialect and the invalid-pattern error; a null cell stays null and a non-String operand raises TypeMismatch.

#
Expr::str_len_bytes

fn Expr::str_len_bytes(self : Expr) -> Expr

The number of UTF-8 bytes in each cell of a String column, as an Int (Polars str.len_bytes) — the encoded byte length, so an ASCII character is 1, a é is 2, and a supplementary-plane emoji is 4 (vs str_len_chars, which counts every character as 1). Null cells stay null; an all-valid result rides the Numeric fast path.

#
Expr::str_len_chars

fn Expr::str_len_chars(self : Expr) -> Expr

The number of Unicode characters in each cell of a String column, as an Int (Polars str.len_chars): a supplementary-plane character counts once, not as its two UTF-16 code units. Null cells stay null; an all-valid result rides the Numeric fast path like every computed numeric column.

#
Expr::str_pad_end

fn Expr::str_pad_end(self : Expr, width : Int, fill? : Char) -> Expr

Right-pad each cell of a String column with fill until it is width characters long — Polars' str.pad_end, the mirror of str_pad_start.

#
Expr::str_pad_start

fn Expr::str_pad_start(self : Expr, width : Int, fill? : Char) -> Expr

Left-pad each cell of a String column with fill until it is width characters long — Polars' str.pad_start. A cell already at least width characters is unchanged (never truncated); null cells stay null. The width counts characters, consistent with str_len_chars.

#
Expr::str_replace

fn Expr::str_replace(self : Expr, pattern : String, value : String, literal? : Bool) -> Expr

Replace the first occurrence of pattern with value in each cell of a String column — Polars' str.replace. literal defaults to true (plain substring); literal=false reads pattern as a POSIX regular expression (see str_contains for the dialect and the invalid-pattern error), with value inserted literally — no capture-group references yet. A cell without a match is unchanged; null cells stay null.

#
Expr::str_replace_all

fn Expr::str_replace_all(self : Expr, pattern : String, value : String, literal? : Bool) -> Expr

Replace every occurrence of pattern with value in each cell (Polars str.replace_all), the all-occurrences mirror of str_replace — including its literal parameter and default.

#
Expr::str_reverse

fn Expr::str_reverse(self : Expr) -> Expr

Reverse the Unicode characters of each cell of a String column — Polars' str.reverse(). Surrogate pairs are respected (reversal is by codepoint, not UTF-16 unit); null cells stay null.

#
Expr::str_slice

fn Expr::str_slice(self : Expr, offset : Int, length? : Int) -> Expr

Substring of each cell by character position — Polars' str.slice. offset is a 0-based character index; a negative offset counts from the end (-2 starts two characters before the end). length is the number of characters (omitted → to the end); a length of zero or less yields the empty string. Both are clamped to the cell — an offset past the end gives "", and a length past the end stops at the end — so it never raises on a value. Character-based (surrogate pairs are never split), consistent with str_len_chars; a null cell stays null and a non-String operand raises TypeMismatch.

#
Expr::str_split_get

fn Expr::str_split_get(self : Expr, sep : String, index : Int) -> Expr

The index-th field of each cell split on the literal separator sep, as a nullable String (a scalar slice of Polars' str.split, which returns a list this repo has no dtype for). index is 0-based; a cell with fewer than index + 1 fields (or a negative index) yields null. "a,b,c" split on "," at index 1 is "b". Null cells stay null.

#
Expr::str_starts_with

fn Expr::str_starts_with(self : Expr, prefix : String) -> Expr

A Bool column that is true where the cell starts with prefix (Polars str.starts_with). Null cells stay null.

#
Expr::str_strip_chars

fn Expr::str_strip_chars(self : Expr, chars? : String) -> Expr

Strip leading and trailing characters from every cell of a String column — Polars' str.strip_chars. With chars omitted, the default set is ASCII whitespace (tab, newline, carriage-return, space); with chars given, every character in that string is a strip target (order and repeats do not matter). Null cells stay null.

#
Expr::str_to_lowercase

fn Expr::str_to_lowercase(self : Expr) -> Expr

Lowercase the ASCII letters of a String column (the shape of Polars' str.to_lowercase, with str_to_uppercase's ASCII-only case-mapping caveat), the case mirror of str_to_uppercase.

#
Expr::str_to_uppercase

fn Expr::str_to_uppercase(self : Expr) -> Expr

Uppercase the ASCII letters of a String column — the shape of Polars' str.to_uppercase, but case mapping is currently ASCII-only (like str_strip_chars' ASCII whitespace set): a non-ASCII letter (é, ß, Cyrillic, …) passes through unchanged rather than mapping. Null cells stay null, the result keeps the operand's column name, and a non-String operand is a TypeMismatch at evaluation. The leading method of the string namespace: every str_* method builds a Str node over a StrOp tag, evaluated cell by cell in internal/kernel/str.mbt.

#
Expr::str_zfill

fn Expr::str_zfill(self : Expr, width : Int) -> Expr

Left-pad each cell of a String column with '0' until it is width characters long — Polars' str.zfill. Like str_pad_start('0') but sign-aware: a leading '+' / '-' keeps its place and the zeros are inserted after it ("-5" to width 4 is "-005", not "00-5"). A cell already at least width characters is unchanged (never truncated); null cells stay null. The width counts characters, consistent with str_len_chars.

#
Expr::sub

fn Expr::sub(self : Expr, other : Expr) -> Expr

#
Expr::sum

fn Expr::sum(self : Expr) -> Expr

Sum of the operand over the evaluation scope (whole frame, or one group under agg). Inherits Series::sum semantics: NaN propagates, nulls are skipped, non-numeric operands are TypeMismatch.

#
Expr::to_repr

#
Expr::to_string

fn Expr::to_string(self : Expr) -> String

#
Expr::variance

fn Expr::variance(self : Expr) -> Expr

Sample variance of the operand over the evaluation scope (ddof = 1); the square of std, with the identical null / NaN / dtype rules. Always a Float. Spelled variance because var is a reserved MoonBit word (the with_alias / .land() situation), so Polars' var becomes variance.

#
Expr::with_alias

fn Expr::with_alias(self : Expr, name : String) -> Expr

Name the result column ((col("revenue") - col("cost")).with_alias("profit")). Without an alias, an expression is named after its leftmost column reference, or "literal" for a column-less tree. Called with_aliasalias itself is a MoonBit reserved word.

#
WhenThen

pub struct WhenThen {
// private fields
}

First step of the conditional chain when(cond).then(a).otherwise(b) — holds the Bool condition until then supplies the matching branch. Constructible only through when, which is a pub function and so cannot return a priv type — that, and nothing else, is why the struct is pub. Its field is private, so the only thing a caller can do with a WhenThen is call then.

#
WhenThen::then

fn WhenThen::then(self : WhenThen, value : Expr) -> WhenThenElse

Supply the branch taken where the condition is true.

#
WhenThenElse

pub struct WhenThenElse {
// private fields
}

Second step of the conditional chain — condition plus then branch, waiting for otherwise to complete the expression. Same private-field rationale as WhenThen: the only move is otherwise.

#
WhenThenElse::otherwise

fn WhenThenElse::otherwise(self : WhenThenElse, value : Expr) -> Expr

Supply the branch taken where the condition is false, completing the conditional as a Ternary expression node.

#
col

fn col(name : String) -> Expr

Reference a column by name, for the Polars-style call shape col("a") + col("b"). The name resolves at evaluation time — ColumnNotFound then if the frame has no such column; building the reference itself is total. A free function, like the lit* family — the facade re-exports it.

#
cols

fn cols(names : Array[String]) -> Array[Expr]

Build a column-reference list from names: cols(["a", "b"]) is exactly [col("a"), col("b")]. The ergonomic shorthand for the common case of projecting (or dropping) several existing columns by name through the expression verbs — df.select(cols(["a", "b"])) reads almost like the names-only projection it replaces. Each name resolves at evaluation time, exactly like col; building the list is total.

#
lit

Embed a literal Scalar. At evaluation it becomes a length-1 column broadcast against its siblings. Prefer the typed shorthands (lit_int / lit_float / lit_str / lit_bool), which avoid spelling the Scalar variant at call sites.

#
lit_bool

fn lit_bool(value : Bool) -> Expr

Bool literal.

#
lit_float

fn lit_float(value : Double) -> Expr

Float literal.

#
lit_int

fn lit_int(value : Int64) -> Expr

Int literal (64-bit, like every MoonFrame integer cell).

#
lit_series

fn lit_series(series :
Series
) -> Expr

Embed a pre-materialised Series as a literal column, so a ready-made column joins a pipeline beside the declarative col(...) ... expressions (df.with_columns([lit_series(s), (col("a") + col("b")).with_alias("c")])). At evaluation the series is used as-is: a length-1 series broadcasts over the evaluation height (like a scalar lit), a series whose length matches the frame supplies one cell per row, and any other length raises LengthMismatch. The result column keeps the series' own name unless with_alias overrides it — so with_columns([lit_series(s)]) adds, or in-place replaces, a column named s.name(). A free function (the argument is a Series, not an Expr), like the lit_* family and map_many. Building the node is total; the length check happens at evaluation, in frame.

#
lit_str

fn lit_str(value : String) -> Expr

String literal.

#
map_many

Apply a host closure across several input columns, row by row — the multi-input escape hatch, and the reified replacement for the original closure filter predicate: f is handed one @types.Scalar per input in inputs order (null cells as Scalar::Null) and is called for every row, so a row predicate is a map_many(..., f) returning a Bool. A free function rather than a method (there is no single self); inputs may mix columns, literals, and aggregations, the length-1 results broadcasting over the row count. Same opacity, totality, dtype inference, naming (after the leftmost input), and value-barrier rules as map_elements — with the dtype fallback reading the leftmost input, whatever it is, so only an empty inputs leaves an all-null result with no witness and raises Unsupported. The inputs array is copied, so mutating it after construction cannot alter the built expression.

#
when

fn when(cond : Expr) -> WhenThen

Open a conditional expression: when(cond).then(a).otherwise(b) evaluates to a where cond is true, b where it is false, and null where cond is null. The chain is the only construction route, so a conditional is complete by the time it becomes an Expr.