Rhai

Rhai

Scripting with Rhai

7 min readOpen in Neblex

When a calculation or reshaping is more than the visual tools can express, write it in Rhai: a small, safe scripting language that looks like a mix of JavaScript and Rust. Rhai runs inside a sandbox with no file, network or system access, and with limits on work and memory, so a script works only on the data its step gives it.

Where you can use Rhai

PlaceWhat you writeVariables you can use
Expression in any mapper or step input (source Expression, or type = in a value)One expression; its value becomes the field.item (read-only), steps, run
Code step, Each record modeA script run once per record.item (changeable), steps, run
Code step, All records modeA script run once over the whole list.items (read-only list), steps, run
Transform ModulesReusable fn definitions imported by Code steps.Only the function parameters

Inside a Loop body you can also read loop_index, loop_item, loop_state and loop_input. Conditions on edges, filters and loops are not Rhai; they use the field/operator/value form.

The variables

  • item: the current record as an object. item.email, item.address.city, item.lines[0].sku. A field that does not exist reads as empty ().
  • items: all records, as a list (All records mode).
  • steps: the output of earlier steps by step id. Each is a list: steps["lookup"][0].price. Values saved with Set value are in steps["$values"][0].name.
  • run: run.id, run.started_at, and for scheduled runs run.occurrence, run.occurrence_utc, run.previous_occurrence, run.time_zone.

Always use the variable name. Write item.price * item.qty, not price * qty. Expressions are checked when you save, and a bare field name is rejected as an unknown variable.

Language basics

You wantWrite
A variablelet total = item.qty * item.unit_price;
A choice (if is an expression)let label = if total > 1000 { "large" } else { "small" };
A listlet tags = ["new", "vip"];
An object (note the #)let customer = #{ name: item.name, email: item.email };
Text with placeholders (backticks)let greeting = `Hello ${item.name}!`;
A loopfor line in item.lines { total += line.amount; } (also while and loop)
A switchlet hours = switch item.priority { "urgent" => 4, "high" => 8, _ => 24 };
A functionfn cents(v) { (v * 100.0).to_int() }
A fallback when a field is missing or emptyif type_of(item.nickname) == "()" || item.nickname == "" { item.name } else { item.nickname }
Stop with your own errorthrow "amount is missing";

Numbers are whole (42) or decimal (42.0); mixing them works. Text uses double quotes. The last expression of a script or block is its value, and return ends early. Statements end with ;.

Function reference

Text

FunctionExampleResult
len, is_empty"abc".len()3
to_upper, to_loweritem.country.to_upper()"DE"
trim *let s = item.name; s.trim(); stext without outer spaces
replace *let s = item.phone; s.replace(" ", ""); sspaces removed
contains, starts_with, ends_withitem.email.ends_with("@acme.com")true
index_of"a-b".index_of("-")1 (or -1)
sub_string(start, length)item.sku.sub_string(0, 3)first three characters
split"a,b,c".split(",")["a", "b", "c"]
pad *, truncate *, crop *let s = id; s.pad(8, "0"); spadded to 8 characters
+, to_stringitem.first + " " + item.lastjoined text
parse_int, parse_floatparse_float("12.50")12.5

* These change the variable in place and return nothing, so copy the value into a variable first, change it, then use the variable.

Numbers

abs, floor, ceiling, round, round_up, round_down, round_half_up, to_int, to_float, sqrt, exp, ln, log, sin/cos/tan, min, max, sign, is_even, is_odd, PI(), E(), and the operators + - * / % **. Round to two decimals with (x * 100.0).round() / 100.0.

Lists

len, push, pop, insert, remove, contains, index_of, filter, map, reduce, some, all, find, sort (optionally with a comparison), reverse, dedup, truncate, extract, retain, zip, and ranges 0..10. Closures use pipes: items.filter(|r| r.amount > 0).

Objects

keys, values, len, contains, remove (returns the removed value), mixin (merge another object in), to_json (object to JSON text), parse_json(text) (JSON object text to an object), and type_of(x) ("string", "i64", "f64", "map", "array", "()"...).

Dates

date_now(), format_date, parse_date, add_minutes, add_hours, add_days, add_weeks, add_months, as described in Working with dates. The number passed to the add_ functions must be a whole number. Example: format_date(add_days(date_now(), -1), "%Y-%m-%d") is yesterday's date.

Large values

Very large values (a long text, a large list or object, a file) are carried by reference instead of in the record. On such a value use len(), byte_len(), sha256(), is_text() and is_json(); reading its content, or a field or position inside it, fails with a message that says so. Returning the value unchanged passes it on intact.

Outside scripts the same holds. Steps that only pass values on, such as mappings, carry a large value unchanged. Steps that need its content, such as filters, sorting or text changes, stop with a clear message instead of treating it as empty. A destination either sends the whole value or stops with a message naming what it could not send; it never sends the reference in its place.

What a Code step returns

  • Each record: change item and end with a statement, and the changed item is the output; or end with an object #{ ... } to output exactly that. Returning a list or a plain value is an error; switch to All records to emit several records.
  • All records: the script's final value is the output. A list becomes that many records; anything else becomes one record. items is read-only: for r in items { r.x = 1 } changes copies, so use items.map(...) and return the result, or copy first with let out = items;.

Examples

Each record: calculate and tag

item.gross = (item.net * 1.19 * 100.0).round() / 100.0;
item.sla_hours = switch item.priority { "urgent" => 4, "high" => 8, _ => 24 };

Each record: output only some fields

#{ label: item.name, total: item.quantity * item.unit_price }

Each record: clean an email address

let e = item.email; e.trim(); item.email = e.to_lower();

Each record: enrich from another step

let m = steps["customers"].filter(|r| r.id == item.customer_id);
if m.len() > 0 { item.customer_name = m[0].name }

Each record: dates

item.due = add_days(item.created_at, 30);
item.due_label = format_date(item.due, "%d.%m.%Y", "Europe/Berlin");

All records: add a field to every record

items.map(|r| { r.imported_at = date_now(); r })

All records: remove duplicates by email

let seen = #{};
items.filter(|r| { let k = r.email; if seen.contains(k) { false } else { seen[k] = true; true } })

All records: count per email

let groups = #{};
for r in items { let k = r.email; if groups.contains(k) { groups[k] += 1 } else { groups[k] = 1 } }
let out = [];
for k in groups.keys() { out.push(#{ email: k, count: groups[k] }); }
out

All records: top three by amount

let out = items;
out.sort(|a, b| if a.amount < b.amount { 1 } else if a.amount > b.amount { -1 } else { 0 });
out.truncate(3);
out

All records: handle text that should be a number

items.map(|t| {
  let a = if type_of(t.amount) == "string" { parse_float(t.amount) } else { t.amount };
  t.priority = if a >= 5000 { 5 } else if a >= 1000 { 3 } else { 1 };
  t
})

Expressions in the mapper

item.price * item.qty
steps["lookup"][0].price * item.qty
if item.amount > 100 { "vip" } else { "standard" }
item.first_name + " " + item.last_name
format_date(item.created_at, "%Y-%m-%d")

Using a Transform Module

Module money contains fn cents(value) { (value * 100.0).to_int() }. A Code step lists money under Imported modules and uses item.cents = cents(item.amount). Modules can be used in Code steps (not in single mapper expressions), and the step preview does not load them; test module calls with a flow test run.

Limits and errors

LimitValue
Work per expression50,000 operations per evaluation
Work per record (Each record)500,000 operations
Work per step (All records)5,000,000 operations
Text length5,000,000 characters
List or object size1,000,000 entries
Nesting / function call depth128 / 64
Script size100 KB (Code step and Transform Module)
Not availableimport, eval, clock functions other than the date functions above, file, network and system access

Errors show the message and its line and position, for example Variable not found: price (line 1, position 1), Function not found: ..., Too many operations (make the script do less work per record, or split the job), or your own throw text. The Transform Studio shows Compiled or Syntax error as you type, and Live output shows the result on sample data. A Code step on a branch that receives no records does not run.

Let Matt write it. In Transform Studio, Write with Matt turns a sentence into a checked expression, and on a Code step Matt for Code drafts a complete Rhai script that must compile and is tested against sample data before you can apply it. See Matt.