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
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 errorThe 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) → anyChecks 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 errorOne 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.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/formulas.qwer$ curl -O https://quillolang.org/plugins/files/formulas.qweThen, in your program, all of it or only what you call:
use formulas.qweruse formula_eval, formula_check from formulas // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
evalexpr = "11"Example
// 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
//! 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())}Related
Thread 0 posts
On Formulas: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.