Skip to main content

lex_runtime/
df.rs

1//! `std.df` — Polars-backed query ops over `arrow.Table` (#427).
2//!
3//! The companion to `std.arrow`. Where `std.arrow` covers construction +
4//! column reductions, `std.df` covers the query-shaped operations —
5//! `filter`, `sort`, `group_by + agg`, `join` — that Polars already
6//! does vectorised + parallel. Same input/output type (`Value::ArrowTable`);
7//! the Polars `DataFrame` is internal plumbing.
8//!
9//! Conversion across the arrow-rs ↔ polars-arrow boundary is a
10//! column-by-column copy (typed buffer → `Vec<T>` → `Series`). For
11//! primitive columns this is a `memcpy`-speed walk; for `String`
12//! columns it copies the offsets + bytes. On the scale `lex-frame`
13//! cares about (≤ 10M rows) this is ~10 ms each direction, negligible
14//! compared to the savings on the actual query.
15
16use arrow_array::{
17    Array, Float64Array, Int64Array, RecordBatch, StringArray,
18};
19use arrow_schema::{DataType as ArrowDt, Field, Schema};
20use lex_bytecode::Value;
21use polars::prelude::{
22    col, lit, Column, DataFrame, DataType as PlDt, Expr, IntoLazy, JoinArgs,
23    JoinType, NamedFrom, PlSmallStr, Series, SortMultipleOptions,
24};
25use polars::prelude::IntoColumn;
26use std::collections::VecDeque;
27use std::sync::Arc;
28
29// ---------- helpers ----------
30
31fn err<T>(s: impl Into<String>) -> Result<T, String> { Err(s.into()) }
32
33fn expect_table(v: Option<&Value>) -> Result<&Arc<RecordBatch>, String> {
34    match v {
35        Some(Value::ArrowTable(t)) => Ok(t),
36        Some(other) => err(format!("df: expected arrow.Table, got {other:?}")),
37        None => err("df: expected arrow.Table, got nothing"),
38    }
39}
40
41fn expect_str(v: Option<&Value>) -> Result<&str, String> {
42    match v {
43        Some(Value::Str(s)) => Ok(s.as_str()),
44        Some(other) => err(format!("df: expected Str, got {other:?}")),
45        None => err("df: expected Str, got nothing"),
46    }
47}
48
49fn expect_int(v: Option<&Value>) -> Result<i64, String> {
50    match v {
51        Some(Value::Int(n)) => Ok(*n),
52        Some(other) => err(format!("df: expected Int, got {other:?}")),
53        None => err("df: expected Int, got nothing"),
54    }
55}
56
57fn expect_float(v: Option<&Value>) -> Result<f64, String> {
58    match v {
59        Some(Value::Float(f)) => Ok(*f),
60        Some(Value::Int(n)) => Ok(*n as f64),
61        Some(other) => err(format!("df: expected Float, got {other:?}")),
62        None => err("df: expected Float, got nothing"),
63    }
64}
65
66fn expect_bool(v: Option<&Value>) -> Result<bool, String> {
67    match v {
68        Some(Value::Bool(b)) => Ok(*b),
69        Some(other) => err(format!("df: expected Bool, got {other:?}")),
70        None => err("df: expected Bool, got nothing"),
71    }
72}
73
74fn expect_list(v: Option<&Value>) -> Result<&VecDeque<Value>, String> {
75    match v {
76        Some(Value::List(items)) => Ok(items),
77        Some(other) => err(format!("df: expected List, got {other:?}")),
78        None => err("df: expected List, got nothing"),
79    }
80}
81
82// ---------- conversion: arrow-rs RecordBatch ↔ polars DataFrame ----------
83
84/// Build a Polars `DataFrame` from an arrow-rs `RecordBatch`. Each
85/// column is copied through `Vec<Option<T>>` so nulls survive the
86/// round-trip (otherwise `df.filter_isnull` would never see them);
87/// cost is O(rows) memcpy-speed.
88fn to_polars(rb: &RecordBatch) -> Result<DataFrame, String> {
89    let mut cols: Vec<Column> = Vec::with_capacity(rb.num_columns());
90    for (idx, field) in rb.schema().fields().iter().enumerate() {
91        let name = field.name();
92        let arr = rb.column(idx);
93        let s = match arr.data_type() {
94            ArrowDt::Int64 => {
95                let a = arr.as_any().downcast_ref::<Int64Array>().unwrap();
96                let buf: Vec<Option<i64>> = (0..a.len()).map(|i|
97                    if a.is_null(i) { None } else { Some(a.value(i)) }
98                ).collect();
99                Series::new(PlSmallStr::from_str(name), buf)
100            }
101            ArrowDt::Float64 => {
102                let a = arr.as_any().downcast_ref::<Float64Array>().unwrap();
103                let buf: Vec<Option<f64>> = (0..a.len()).map(|i|
104                    if a.is_null(i) { None } else { Some(a.value(i)) }
105                ).collect();
106                Series::new(PlSmallStr::from_str(name), buf)
107            }
108            ArrowDt::Utf8 => {
109                let a = arr.as_any().downcast_ref::<StringArray>().unwrap();
110                let buf: Vec<Option<&str>> = (0..a.len()).map(|i|
111                    if a.is_null(i) { None } else { Some(a.value(i)) }
112                ).collect();
113                Series::new(PlSmallStr::from_str(name), buf)
114            }
115            other => return err(format!(
116                "df: column `{name}` has unsupported type {other:?} (v1: Int64/Float64/Utf8)")),
117        };
118        cols.push(s.into());
119    }
120    // polars 0.53 switched `DataFrame::new(cols)` to take an explicit
121    // height as the first arg; `new_infer_height` does what the v0.50
122    // `new` did, deriving height from the first column.
123    DataFrame::new_infer_height(cols).map_err(|e| format!("df: build DataFrame: {e}"))
124}
125
126/// Build an arrow-rs `RecordBatch` from a Polars `DataFrame`. Inverse
127/// of `to_polars`, same O(rows) copy cost per column. Nulls are
128/// preserved — output fields are emitted with `nullable=true` so the
129/// arrow schema reflects what the polars-side filter / agg may have
130/// produced.
131fn from_polars(df: &DataFrame) -> Result<RecordBatch, String> {
132    let mut fields: Vec<Field> = Vec::with_capacity(df.width());
133    let mut arrays: Vec<arrow_array::ArrayRef> = Vec::with_capacity(df.width());
134    for column in df.columns() {
135        let name = column.name().as_str();
136        let s = column.as_materialized_series();
137        let (field, array): (Field, arrow_array::ArrayRef) = match s.dtype() {
138            PlDt::Int64 => {
139                let v: Vec<Option<i64>> = s.i64()
140                    .map_err(|e| format!("df: column `{name}` as i64: {e}"))?
141                    .iter().collect();
142                (
143                    Field::new(name, ArrowDt::Int64, true),
144                    Arc::new(Int64Array::from(v)),
145                )
146            }
147            PlDt::Float64 => {
148                let v: Vec<Option<f64>> = s.f64()
149                    .map_err(|e| format!("df: column `{name}` as f64: {e}"))?
150                    .iter().collect();
151                (
152                    Field::new(name, ArrowDt::Float64, true),
153                    Arc::new(Float64Array::from(v)),
154                )
155            }
156            PlDt::String => {
157                // Collect `Option<&str>` straight into the arrow buffer.
158                // The previous `x.map(|s| s.to_string())` detour allocated
159                // one heap String per row — ~300 ms of pure allocator
160                // traffic on a 1M-row × 3-string-column table, enough to
161                // eat the entire win of the parallel CSV reader on
162                // string-heavy files.
163                let arr: StringArray = s.str()
164                    .map_err(|e| format!("df: column `{name}` as Utf8: {e}"))?
165                    .iter().collect();
166                (
167                    Field::new(name, ArrowDt::Utf8, true),
168                    Arc::new(arr),
169                )
170            }
171            // UInt32 surfaces from `count` aggregations in Polars.
172            // Width promotes to Int64 (lex `Int` is 64-bit).
173            PlDt::UInt32 => {
174                let v: Vec<Option<i64>> = s.u32()
175                    .map_err(|e| format!("df: column `{name}` as u32: {e}"))?
176                    .iter().map(|x| x.map(|n| n as i64)).collect();
177                (
178                    Field::new(name, ArrowDt::Int64, true),
179                    Arc::new(Int64Array::from(v)),
180                )
181            }
182            other => return err(format!(
183                "df: polars column `{name}` has unsupported type {other:?}")),
184        };
185        fields.push(field);
186        arrays.push(array);
187    }
188    let schema = Arc::new(Schema::new(fields));
189    RecordBatch::try_new(schema, arrays)
190        .map_err(|e| format!("df: RecordBatch::try_new: {e}"))
191}
192
193// ---------- ops ----------
194
195fn pack(df: DataFrame) -> Result<Value, String> {
196    let rb = from_polars(&df)?;
197    Ok(Value::ArrowTable(Arc::new(rb)))
198}
199
200fn filter_eq_int(args: &[Value]) -> Result<Value, String> {
201    let rb = expect_table(args.first())?;
202    let col_name = expect_str(args.get(1))?;
203    let needle = expect_int(args.get(2))?;
204    let df = to_polars(rb)?;
205    let out = df.lazy()
206        .filter(col(col_name).eq(lit(needle)))
207        .collect()
208        .map_err(|e| format!("df.filter_eq_int: {e}"))?;
209    pack(out)
210}
211
212fn filter_gt_int(args: &[Value]) -> Result<Value, String> {
213    let rb = expect_table(args.first())?;
214    let col_name = expect_str(args.get(1))?;
215    let needle = expect_int(args.get(2))?;
216    let df = to_polars(rb)?;
217    let out = df.lazy()
218        .filter(col(col_name).gt(lit(needle)))
219        .collect()
220        .map_err(|e| format!("df.filter_gt_int: {e}"))?;
221    pack(out)
222}
223
224fn filter_lt_int(args: &[Value]) -> Result<Value, String> {
225    let rb = expect_table(args.first())?;
226    let col_name = expect_str(args.get(1))?;
227    let needle = expect_int(args.get(2))?;
228    let df = to_polars(rb)?;
229    let out = df.lazy()
230        .filter(col(col_name).lt(lit(needle)))
231        .collect()
232        .map_err(|e| format!("df.filter_lt_int: {e}"))?;
233    pack(out)
234}
235
236/// Type-check `col_name` against `wanted` before letting Polars run.
237/// The polars error for a type-mismatched filter is opaque
238/// ("cannot compare Int64 with Utf8"); this lets us return a stable
239/// shape like "expected utf8 column, got int64". Caller passes the
240/// `RecordBatch` we'll convert to polars, so we use the arrow schema
241/// (which is what an agent saw via `arrow.col_type`).
242fn expect_col_type(rb: &RecordBatch, col_name: &str, wanted: ArrowDt, op: &str) -> Result<(), String> {
243    let schema = rb.schema();
244    let (_, field) = schema
245        .column_with_name(col_name)
246        .ok_or_else(|| format!("df.{op}: column `{col_name}` not found"))?;
247    if field.data_type() != &wanted {
248        return err(format!(
249            "df.{op}: expected {wanted:?} column, got {:?}",
250            field.data_type()
251        ));
252    }
253    Ok(())
254}
255
256fn filter_eq_str(args: &[Value]) -> Result<Value, String> {
257    let rb = expect_table(args.first())?;
258    let col_name = expect_str(args.get(1))?;
259    let needle = expect_str(args.get(2))?;
260    expect_col_type(rb, col_name, ArrowDt::Utf8, "filter_eq_str")?;
261    let df = to_polars(rb)?;
262    let out = df.lazy()
263        .filter(col(col_name).eq(lit(needle.to_string())))
264        .collect()
265        .map_err(|e| format!("df.filter_eq_str: {e}"))?;
266    pack(out)
267}
268
269fn filter_in_str(args: &[Value]) -> Result<Value, String> {
270    let rb = expect_table(args.first())?;
271    let col_name = expect_str(args.get(1))?;
272    let needles_list = expect_list(args.get(2))?;
273    expect_col_type(rb, col_name, ArrowDt::Utf8, "filter_in_str")?;
274    let mut needles: Vec<String> = Vec::with_capacity(needles_list.len());
275    for v in needles_list {
276        match v {
277            Value::Str(s) => needles.push(s.to_string()),
278            other => return err(format!(
279                "df.filter_in_str: needle list contained non-Str: {other:?}")),
280        }
281    }
282    // Empty needle list → empty result (SQL `IN ()` is false).
283    if needles.is_empty() {
284        // Build an empty version of `rb` using its existing schema —
285        // saves the round-trip through polars for a degenerate input.
286        let empty = RecordBatch::new_empty(rb.schema());
287        return Ok(Value::ArrowTable(Arc::new(empty)));
288    }
289    let df = to_polars(rb)?;
290    let needle_series: Series =
291        Series::new(PlSmallStr::from_static("__in"), needles).into_column().take_materialized_series();
292    let out = df.lazy()
293        .filter(col(col_name).is_in(lit(needle_series), false))
294        .collect()
295        .map_err(|e| format!("df.filter_in_str: {e}"))?;
296    pack(out)
297}
298
299fn filter_eq_float(args: &[Value]) -> Result<Value, String> {
300    let rb = expect_table(args.first())?;
301    let col_name = expect_str(args.get(1))?;
302    let needle = expect_float(args.get(2))?;
303    expect_col_type(rb, col_name, ArrowDt::Float64, "filter_eq_float")?;
304    let df = to_polars(rb)?;
305    let out = df.lazy()
306        .filter(col(col_name).eq(lit(needle)))
307        .collect()
308        .map_err(|e| format!("df.filter_eq_float: {e}"))?;
309    pack(out)
310}
311
312fn filter_lt_float(args: &[Value]) -> Result<Value, String> {
313    let rb = expect_table(args.first())?;
314    let col_name = expect_str(args.get(1))?;
315    let needle = expect_float(args.get(2))?;
316    expect_col_type(rb, col_name, ArrowDt::Float64, "filter_lt_float")?;
317    let df = to_polars(rb)?;
318    let out = df.lazy()
319        .filter(col(col_name).lt(lit(needle)))
320        .collect()
321        .map_err(|e| format!("df.filter_lt_float: {e}"))?;
322    pack(out)
323}
324
325fn filter_gt_float(args: &[Value]) -> Result<Value, String> {
326    let rb = expect_table(args.first())?;
327    let col_name = expect_str(args.get(1))?;
328    let needle = expect_float(args.get(2))?;
329    expect_col_type(rb, col_name, ArrowDt::Float64, "filter_gt_float")?;
330    let df = to_polars(rb)?;
331    let out = df.lazy()
332        .filter(col(col_name).gt(lit(needle)))
333        .collect()
334        .map_err(|e| format!("df.filter_gt_float: {e}"))?;
335    pack(out)
336}
337
338fn filter_isnull(args: &[Value]) -> Result<Value, String> {
339    let rb = expect_table(args.first())?;
340    let col_name = expect_str(args.get(1))?;
341    // Type-agnostic — works on any column. Just verify the column exists.
342    if rb.schema().column_with_name(col_name).is_none() {
343        return err(format!("df.filter_isnull: column `{col_name}` not found"));
344    }
345    let df = to_polars(rb)?;
346    let out = df.lazy()
347        .filter(col(col_name).is_null())
348        .collect()
349        .map_err(|e| format!("df.filter_isnull: {e}"))?;
350    pack(out)
351}
352
353fn filter_notnull(args: &[Value]) -> Result<Value, String> {
354    let rb = expect_table(args.first())?;
355    let col_name = expect_str(args.get(1))?;
356    if rb.schema().column_with_name(col_name).is_none() {
357        return err(format!("df.filter_notnull: column `{col_name}` not found"));
358    }
359    let df = to_polars(rb)?;
360    let out = df.lazy()
361        .filter(col(col_name).is_not_null())
362        .collect()
363        .map_err(|e| format!("df.filter_notnull: {e}"))?;
364    pack(out)
365}
366
367fn drop_nulls(args: &[Value]) -> Result<Value, String> {
368    let rb = expect_table(args.first())?;
369    let cols_list = expect_list(args.get(1))?;
370    // Empty list → no-op (return the input unchanged).
371    if cols_list.is_empty() {
372        return Ok(Value::ArrowTable(Arc::clone(rb)));
373    }
374    let mut cols: Vec<String> = Vec::with_capacity(cols_list.len());
375    {
376        let schema = rb.schema();
377        for v in cols_list {
378            match v {
379                Value::Str(s) => {
380                    if schema.column_with_name(s.as_str()).is_none() {
381                        return err(format!("df.drop_nulls: column `{s}` not found"));
382                    }
383                    cols.push(s.to_string());
384                }
385                other => return err(format!(
386                    "df.drop_nulls: column list contained non-Str: {other:?}")),
387            }
388        }
389    }
390    let df = to_polars(rb)?;
391    let out = df
392        .drop_nulls(Some(&cols))
393        .map_err(|e| format!("df.drop_nulls: {e}"))?;
394    pack(out)
395}
396
397fn sort_by(args: &[Value]) -> Result<Value, String> {
398    let rb = expect_table(args.first())?;
399    let col_name = expect_str(args.get(1))?;
400    let asc = expect_bool(args.get(2))?;
401    let df = to_polars(rb)?;
402    let mut sort_opts = SortMultipleOptions::default();
403    sort_opts = sort_opts.with_order_descending(!asc);
404    let out = df.lazy()
405        .sort([col_name], sort_opts)
406        .collect()
407        .map_err(|e| format!("df.sort_by: {e}"))?;
408    pack(out)
409}
410
411/// `df.group_by_agg(t, keys, specs)`. `keys :: List[Str]`. Each spec is
412/// `(out_name :: Str, in_name :: Str, op :: Str)` where op ∈ "sum" |
413/// "mean" | "min" | "max" | "count" | "n_distinct".
414fn group_by_agg(args: &[Value]) -> Result<Value, String> {
415    let rb = expect_table(args.first())?;
416    let keys_list = expect_list(args.get(1))?;
417    let specs_list = expect_list(args.get(2))?;
418
419    let mut keys: Vec<&str> = Vec::with_capacity(keys_list.len());
420    for k in keys_list {
421        let s = match k {
422            Value::Str(s) => s.as_str(),
423            other => return err(format!("group_by_agg: key list contained non-Str: {other:?}")),
424        };
425        keys.push(s);
426    }
427
428    let mut aggs: Vec<Expr> = Vec::with_capacity(specs_list.len());
429    for spec in specs_list {
430        let t = match spec {
431            Value::Tuple(t) if t.len() == 3 => t,
432            other => return err(format!(
433                "group_by_agg: spec must be (out, in, op) tuple, got {other:?}")),
434        };
435        let out_name = match &t[0] {
436            Value::Str(s) => s.as_str(),
437            other => return err(format!("group_by_agg: out_name not Str: {other:?}")),
438        };
439        let in_name = match &t[1] {
440            Value::Str(s) => s.as_str(),
441            other => return err(format!("group_by_agg: in_name not Str: {other:?}")),
442        };
443        let op = match &t[2] {
444            Value::Str(s) => s.as_str(),
445            other => return err(format!("group_by_agg: op not Str: {other:?}")),
446        };
447        let e = match op {
448            "sum"        => col(in_name).sum().alias(out_name),
449            "mean"       => col(in_name).mean().alias(out_name),
450            "min"        => col(in_name).min().alias(out_name),
451            "max"        => col(in_name).max().alias(out_name),
452            "count"      => col(in_name).count().alias(out_name),
453            "n_distinct" => col(in_name).n_unique().alias(out_name),
454            other => return err(format!(
455                "group_by_agg: unknown op `{other}` (v1: sum|mean|min|max|count|n_distinct)")),
456        };
457        aggs.push(e);
458    }
459
460    let df = to_polars(rb)?;
461    let out = df.lazy()
462        .group_by(keys.iter().map(|k| col(*k)).collect::<Vec<_>>())
463        .agg(aggs)
464        .collect()
465        .map_err(|e| format!("df.group_by_agg: {e}"))?;
466    pack(out)
467}
468
469fn inner_join(args: &[Value]) -> Result<Value, String> {
470    let lhs = expect_table(args.first())?;
471    let rhs = expect_table(args.get(1))?;
472    let on = expect_str(args.get(2))?;
473    let l = to_polars(lhs)?;
474    let r = to_polars(rhs)?;
475    let out = l.lazy()
476        .join(r.lazy(), [col(on)], [col(on)], JoinArgs::new(JoinType::Inner))
477        .collect()
478        .map_err(|e| format!("df.inner_join: {e}"))?;
479    pack(out)
480}
481
482fn left_join(args: &[Value]) -> Result<Value, String> {
483    let lhs = expect_table(args.first())?;
484    let rhs = expect_table(args.get(1))?;
485    let on = expect_str(args.get(2))?;
486    let l = to_polars(lhs)?;
487    let r = to_polars(rhs)?;
488    let out = l.lazy()
489        .join(r.lazy(), [col(on)], [col(on)], JoinArgs::new(JoinType::Left))
490        .collect()
491        .map_err(|e| format!("df.left_join: {e}"))?;
492    pack(out)
493}
494
495fn cross_join(args: &[Value]) -> Result<Value, String> {
496    let lhs = expect_table(args.first())?;
497    let rhs = expect_table(args.get(1))?;
498    let l = to_polars(lhs)?;
499    let r = to_polars(rhs)?;
500    let out = l.lazy()
501        .cross_join(r.lazy(), None)
502        .collect()
503        .map_err(|e| format!("df.cross_join: {e}"))?;
504    pack(out)
505}
506
507// ---------- I/O: the Polars-backed CSV reader behind arrow.read_csv ----------
508
509/// Read a CSV through Polars' parallel reader and hand back the usual
510/// `Value::ArrowTable`. This is what `arrow.read_csv` dispatches to
511/// when the `df` feature is on (the default for the `lex` toolchain):
512/// the arrow-rs CSV reader is single-threaded and was measured at
513/// ~10x the wall time of Polars' on a 1M-row file — it dominated
514/// every read-then-query pipeline (see lex-frame's bench/REPORT.md).
515///
516/// Contract is unchanged from the arrow-rs path: header row required,
517/// schema inferred from the first 100 rows, output columns normalised
518/// to the `std.arrow` v1 dtype surface (Int64 / Float64 / Utf8).
519/// Polars may infer types outside that surface (Boolean today;
520/// narrower ints defensively) — those are cast: ints widen to Int64,
521/// Float32 widens to Float64, everything else (Boolean, temporal)
522/// renders to Utf8. The old reader produced unusable columns for
523/// those inputs (present in the table, rejected by every kernel), so
524/// the cast is a strict upgrade, not a break.
525pub fn read_csv_at_polars(path: &std::path::Path) -> Result<Value, String> {
526    use polars::prelude::{CsvReadOptions, SerReader};
527
528    let df = CsvReadOptions::default()
529        .with_has_header(true)
530        .with_infer_schema_length(Some(100))
531        .try_into_reader_with_file_path(Some(path.to_path_buf()))
532        .map_err(|e| format!("arrow.read_csv: open `{}`: {e}", path.display()))?
533        .finish()
534        .map_err(|e| format!("arrow.read_csv: parse `{}`: {e}", path.display()))?;
535
536    // Normalise to the v1 dtype surface before crossing back to arrow.
537    let mut casts: Vec<Expr> = Vec::new();
538    for (name, dtype) in df.schema().iter() {
539        let target = match dtype {
540            PlDt::Int64 | PlDt::Float64 | PlDt::String => continue,
541            PlDt::Int8
542            | PlDt::Int16
543            | PlDt::Int32
544            | PlDt::UInt8
545            | PlDt::UInt16
546            | PlDt::UInt32
547            | PlDt::UInt64 => PlDt::Int64,
548            PlDt::Float32 => PlDt::Float64,
549            _ => PlDt::String,
550        };
551        casts.push(col(name.as_str()).cast(target));
552    }
553    let df = if casts.is_empty() {
554        df
555    } else {
556        df.lazy()
557            .with_columns(casts)
558            .collect()
559            .map_err(|e| format!("arrow.read_csv: dtype normalisation: {e}"))?
560    };
561    pack(df)
562}
563
564// ---------- helpers (mirror arrow.rs) ----------
565
566fn ok(v: Value) -> Value {
567    Value::Variant { name: "Ok".into(), args: vec![v] }
568}
569
570fn err_variant(s: String) -> Value {
571    Value::Variant { name: "Err".into(), args: vec![Value::Str(s.into())] }
572}
573
574fn lift_result(r: Result<Value, String>) -> Result<Value, String> {
575    match r {
576        Ok(v)  => Ok(ok(v)),
577        Err(s) => Ok(err_variant(s)),
578    }
579}
580
581// ---------- public dispatch ----------
582
583pub fn dispatch(op: &str, args: &[Value]) -> Option<Result<Value, String>> {
584    Some(match op {
585        "filter_eq_int"   => lift_result(filter_eq_int(args)),
586        "filter_gt_int"   => lift_result(filter_gt_int(args)),
587        "filter_lt_int"   => lift_result(filter_lt_int(args)),
588        // #433 — string/float/null filter predicates.
589        "filter_eq_str"   => lift_result(filter_eq_str(args)),
590        "filter_in_str"   => lift_result(filter_in_str(args)),
591        "filter_eq_float" => lift_result(filter_eq_float(args)),
592        "filter_lt_float" => lift_result(filter_lt_float(args)),
593        "filter_gt_float" => lift_result(filter_gt_float(args)),
594        "filter_isnull"   => lift_result(filter_isnull(args)),
595        "filter_notnull"  => lift_result(filter_notnull(args)),
596        "drop_nulls"      => lift_result(drop_nulls(args)),
597        "sort_by"         => lift_result(sort_by(args)),
598        "group_by_agg"    => lift_result(group_by_agg(args)),
599        "inner_join"      => lift_result(inner_join(args)),
600        "left_join"       => lift_result(left_join(args)),
601        "cross_join"      => lift_result(cross_join(args)),
602        _ => return None,
603    })
604}