Numbers

Formulas

Spreadsheet-like formulas users write, such as if(total > 100, 0, 4.95), evaluated safely with their variables, checked before they are saved.

Version 1.0.0 · 3 functions · 1 crate

#formulas#rules#pricing#spreadsheets

Functions

What use formulas.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use formula_eval, formula_check from formulas (Quillo 0.6).

formula_eval(formula: str, vars: map) → anyor an error

The value of formula with vars ({ price: 12.5, qty: 3 }). Numbers given are decimals; results are rounded to 9 decimals, and whole ones come back as ints. Operators: + - * / % ^, comparisons, && || !, text in "quotes". Functions: if(c, a, b), min, max, floor, round, ceil, math::abs, math::sqrt, str::to_uppercase, len... A number written in the formula without a point is an integer (7 / 2 is 3; write 7.0 / 2). A variable not given, or a type mismatch, is an error value.

formula_check(formula: str, allowed: list) → any

Checks a formula before it is saved: { ok, error, variables, functions, unknown }: the variables it reads, the functions it calls, and those of its variables not in allowed (all of them are fine when allowed is empty). Nothing is evaluated.

formula_eval_rows(formula: str, rows: list) → listor an error

One formula over many rows, as a computed column: the value for each row (a map of variables), or { error } for a row where it fails. At most 100,000 rows.

Get it

$ quillo install formulas               # formulas.qwer into this folder$ quillo install formulas --example     # and its example, formulas.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/formulas.qwer$ curl -O https://quillolang.org/plugins/files/formulas.qwe

Then, in your program, all of it or only what you call:

main.qwe
use formulas.qweruse formula_eval, formula_check from formulas      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
evalexpr = "11"

Example

formulas.qwe
// Pricing rules that shop managers type in the admin, checked when saved and run on each cart.////   quillo run formulas.qwe      then open http://localhost:8080/cartuse "formulas.qwer"let ALLOWED = ["subtotal", "items", "country", "member"]let rules = {    shipping: "if(subtotal >= 50.0 || member, 0, if(country == \"NL\", 4.95, 9.95))",    discount: "if(items >= 10, round(subtotal * 0.1 * 100) / 100, 0)",}route GET /cart ? { subtotal: float = 62.4, items: int = 12, country: str = "BE", member: bool = false } {    let vars = { subtotal, items, country, member }    let shipping = formula_eval(rules.shipping, vars)?    let discount = formula_eval(rules.discount, vars)?    { subtotal, shipping, discount, total: subtotal + shipping - discount }}route GET /save ? { formula: str } {    let c = formula_check(formula, ALLOWED)    if !c.ok { return { saved: false, error: c.error } }    { saved: true, reads: c.variables, calls: c.functions }}route GET /margins = formula_eval_rows("(price - cost) / price * 100", [    { price: 20, cost: 12 }, { price: 9.5, cost: 7.6 }, { price: 0, cost: 1 }, { price: 15 },])?
$ quillo run formulas.qwe$ curl 'localhost:8080/cart'$ curl 'localhost:8080/save?formula=subtotal%20*%20vat'$ curl 'localhost:8080/margins'

Source

formulas.qwer
//! Spreadsheet-like formulas your users write (`price * qty * (1 - discount)`, `if(total > 100, 0, 4.95)`), evaluated safely with their variables: no loops, no access to anything but the values given.//! version: 1.0.0//!//! [dependencies]//! evalexpr = "11"use evalexpr::{ContextWithMutableVariables, HashMapContext, Value as E};use serde_json::{json, Value};use std::collections::{BTreeSet, HashMap};const MAX_FORMULA: usize = 10_000;fn to_e(v: &Value) -> E {    match v {        Value::Null => E::Empty,        Value::Bool(b) => E::Boolean(*b),        Value::Number(n) => E::Float(n.as_f64().unwrap_or(0.0)),        Value::String(s) => E::String(s.clone()),        Value::Array(xs) => E::Tuple(xs.iter().map(to_e).collect()),        Value::Object(_) => E::String(v.to_string()),    }}/// The result as JSON: floats rounded to 9 decimals (so 0.1 + 0.2 is 0.3), whole ones as ints; infinity is an error.fn to_json(v: E) -> Result<Value, String> {    Ok(match v {        E::Empty => Value::Null,        E::Boolean(b) => json!(b),        E::Int(i) => json!(i),        E::Float(f) if !f.is_finite() => return Err("the formula divides by zero, or its result is too large".into()),        E::Float(f) => {            let f = if f.abs() < 1e9 { (f * 1e9).round() / 1e9 } else { f };            if f.fract() == 0.0 && f.abs() < 9e15 { json!(f as i64) } else { json!(f) }        }        E::String(s) => json!(s),        E::Tuple(xs) => Value::Array(xs.into_iter().map(to_json).collect::<Result<_, _>>()?),    })}fn context(vars: &HashMap<String, Value>) -> Result<HashMapContext, String> {    let mut ctx = HashMapContext::new();    for (k, v) in vars {        ctx.set_value(k.clone(), to_e(v)).map_err(|e| e.to_string())?;    }    Ok(ctx)}fn tree(formula: &str) -> Result<evalexpr::Node, String> {    if formula.len() > MAX_FORMULA {        return Err("a formula is at most 10,000 characters".into());    }    evalexpr::build_operator_tree(formula).map_err(|e| e.to_string())}/// The value of `formula` with `vars` (`{ price: 12.5, qty: 3 }`). Numbers given are decimals; results are rounded to 9 decimals, and whole ones come back as ints./// Operators: `+ - * / % ^`, comparisons, `&& || !`, text in "quotes". Functions: `if(c, a, b)`, `min`, `max`, `floor`, `round`, `ceil`, `math::abs`, `math::sqrt`, `str::to_uppercase`, `len`.../// A number written in the formula without a point is an integer (`7 / 2` is 3; write `7.0 / 2`). A variable not given, or a type mismatch, is an error value.pub fn formula_eval(formula: &str, vars: std::collections::HashMap<String, serde_json::Value>) -> Result<Value, String> {    let t = tree(formula)?;    to_json(t.eval_with_context(&context(&vars)?).map_err(|e| e.to_string())?)}/// Checks a formula before it is saved: `{ ok, error, variables, functions, unknown }`: the variables it reads, the functions it calls,/// and those of its variables not in `allowed` (all of them are fine when `allowed` is empty). Nothing is evaluated.pub fn formula_check(formula: &str, allowed: Vec<String>) -> Value {    let t = match tree(formula) {        Ok(t) => t,        Err(e) => return json!({ "ok": false, "error": e, "variables": [], "functions": [], "unknown": [] }),    };    let vars: BTreeSet<&str> = t.iter_read_variable_identifiers().collect();    let funcs: BTreeSet<&str> = t.iter_function_identifiers().collect();    let writes = t.iter_variable_identifiers().count() > t.iter_read_variable_identifiers().count();    let unknown: Vec<&str> = if allowed.is_empty() { vec![] } else { vars.iter().copied().filter(|v| !allowed.iter().any(|a| a == v)).collect() };    let error = if writes {        Some("a formula cannot assign to variables".to_string())    } else if !unknown.is_empty() {        Some(format!("unknown variable{}: {}", if unknown.len() > 1 { "s" } else { "" }, unknown.join(", ")))    } else {        None    };    json!({ "ok": error.is_none(), "error": error, "variables": vars, "functions": funcs, "unknown": unknown })}/// One formula over many rows, as a computed column: the value for each row (a map of variables), or `{ error }` for a row where it fails. At most 100,000 rows.pub fn formula_eval_rows(formula: &str, rows: Vec<std::collections::HashMap<String, serde_json::Value>>) -> Result<Vec<Value>, String> {    if rows.len() > 100_000 {        return Err("at most 100,000 rows at once".into());    }    let t = tree(formula)?;    Ok(rows        .iter()        .map(|r| match context(r).and_then(|c| t.eval_with_context(&c).map_err(|e| e.to_string())).and_then(to_json) {            Ok(v) => v,            Err(e) => json!({ "error": e }),        })        .collect())}
Thread
87Useful? Like it, or add to its thread below.

Thread 0 posts

On Formulas: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.