fv-value 0.1.1

An expression dialect for computing values over rows: arithmetic, strings, dates, lists, logic and geospatial functions, with an extensible function registry.
Documentation

fv-value

crates.io docs.rs ci

A small expression dialect for computing values over rows.

status == 'ACTIVE' && roundTo(speed * 1.852, 1) > 100
coalesce(nickname, split(email, '@')[0])
geoWithinVolume(zone, position, altitudeM, 5000, 12000) ? 'inside' : 'outside'

Arithmetic, strings, dates, lists, logic and geospatial functions (2D predicates and 3D geodesy), with strict booleans and null propagation. Compile an expression once, evaluate it per row against any scope — a map, or a row slice with no per-row allocation. Add your own functions as closures, remove ones you do not want, or expose only the set you choose.

Install

cargo add fv-value

Quick start

use fv_value::{compile, Scope, Value};

let expr = compile("status == 'ACTIVE' && roundTo(speed * 1.852, 1) > 100").unwrap();
assert_eq!(expr.identifiers(), ["status", "speed"]);   // the columns it reads

let row: Scope = [
    ("status".to_string(), Value::from("ACTIVE")),
    ("speed".to_string(), Value::from(60)),
].into();
assert_eq!(expr.eval(&row).unwrap(), Value::Bool(true));

Every Rust example in this README is compiled and run as a doctest by CI.

Guide

Compile once, evaluate per row

compile parses and checks an expression: syntax, function names and arities are all errors at compile time, before any row is seen. The Expr it returns is Send + Sync, cheap to clone, and evaluates against anything that implements Lookup:

use fv_value::{compile, Value};

let expr = compile("price * qty").unwrap();

// A row slice: no map built per row — the shape for hot loops.
let rows: Vec<Vec<(String, Value)>> = vec![
    vec![("price".into(), 2.5.into()), ("qty".into(), 4.into())],
    vec![("price".into(), 1.0.into())],                       // qty missing → null
];
let totals: Vec<Value> = rows.iter().map(|r| expr.eval(r.as_slice()).unwrap()).collect();
assert_eq!(totals, vec![Value::Num(10.0), Value::Null]);

Scope (a BTreeMap), HashMap<String, Value>, Vec<(String, Value)> and Empty all implement Lookup; implement it yourself to read straight from your own row type.

Values

One numeric type (f64), strings, booleans, null, lists and objects. Value converts from the obvious Rust types and prints as JSON:

use fv_value::{evaluate, Empty, Value};

assert_eq!(Value::from(42), Value::Num(42.0));
assert_eq!(Value::from(vec!["a", "b"]).to_string(), r#"["a","b"]"#);
assert_eq!(Value::from(None::<i64>), Value::Null);

// Objects and lists are navigable.
assert_eq!(evaluate("[10, 20, 30][1]", &Empty).unwrap(), Value::Num(20.0));
assert_eq!(evaluate("'a,b,c' in ['x', 'a,b,c']", &Empty).unwrap(), Value::Bool(true));

The semantics that matter, all pinned by the conformance vectors:

  • null propagates through arithmetic and most functions: missing + 1 is null.
  • &&, ||, ! and ?: require booleans and short-circuit; !5 is an error, not false.
  • == never equates a boolean with a number: true == 1 is false; null == null is true.
  • < and friends order two numbers or two strings; anything involving null is false.
  • % keeps the dividend's sign; / and % by zero are errors.

Errors are typed

use fv_value::{compile, evaluate, Empty, ExprError};

assert!(matches!(compile("nope(1)").unwrap_err(), ExprError::UnknownFunction { .. }));
assert!(matches!(compile("abs(1, 2)").unwrap_err(), ExprError::Arity { given: 2, .. }));
assert!(matches!(compile("(1 + 2").unwrap_err(), ExprError::Syntax { position: Some(6), .. }));
assert_eq!(evaluate("1 / 0", &Empty).unwrap_err(), ExprError::DivisionByZero);
assert!(matches!(evaluate("'a' - 1", &Empty).unwrap_err(), ExprError::Type { .. }));

// Compile-time errors mean a bad expression (report it to the author); run-time errors mean a
// bad row (skip it, null it, count it).
assert!(compile("nope(1)").unwrap_err().is_compile_error());
assert!(!ExprError::DivisionByZero.is_compile_error());

Your own functions

A Dialect is a Functions registry plus the grammar. Start from the standard set, an empty one, or anything in between; functions are closures over &[Value]:

use fv_value::{Dialect, ExprError, Function, Functions, Scope, Value};

let functions = Functions::standard()
    .with(Function::fixed("celsius", 1, |a: &[Value]| match a[0] {
        Value::Num(f) => Ok(Value::Num((f - 32.0) * 5.0 / 9.0)),
        Value::Null => Ok(Value::Null),
        _ => Err(ExprError::call("celsius: expected a number")),
    }))
    .with(Function::variadic("longest", 1, |a: &[Value]| {
        Ok(a.iter().filter_map(Value::as_str).max_by_key(|s| s.len()).map(Value::from).unwrap_or(Value::Null))
    }))
    .without("regexReplace");
let dialect = Dialect::new(functions);

let row: Scope = [("temp_f".to_string(), Value::from(212))].into();
assert_eq!(dialect.evaluate("celsius(temp_f)", &row).unwrap(), Value::Num(100.0));
assert_eq!(dialect.evaluate("longest('ab', 'abc')", &row).unwrap(), Value::from("abc"));
assert!(matches!(dialect.compile("regexReplace('a', 'b', 'c')"), Err(ExprError::UnknownFunction { .. })));

Arity is checked at compile time from the bounds you declare, so a function body can index its arguments safely. Report a failure with ExprError::call.

The function catalogue

catalogue() describes the standard functions as JSON — names and arity bounds, nothing else — for tooling that validates expressions without evaluating them (an editor, a publish-time check in another language). cargo run --example emit_catalogue prints it.

let cat = fv_value::catalogue();
assert_eq!(cat["functions"]["roundTo"], serde_json::json!({ "min": 2, "max": 2 }));
assert_eq!(cat["functions"]["concat"]["max"], serde_json::Value::Null); // variadic
assert!(cat["functions"].as_object().unwrap().len() >= 100);

Any custom dialect has the same: dialect.catalogue().

Inspecting an expression

Expr::ast() exposes the syntax tree with typed operators and calls already bound to their functions, for lineage, rewriting or translation to another engine:

use fv_value::{compile, Ast, BinaryOp};

let e = compile("a + b * 2").unwrap();
let Ast::Binary(BinaryOp::Add, left, _) = e.ast() else { panic!() };
assert!(matches!(**left, Ast::Id(ref n) if n == "a"));
assert!(!e.is_constant() && compile("1 + 1").unwrap().is_constant());

The standard functions

103 functions in six groups; catalogue() is the authoritative list.

  • Numeric — abs, floor, ceil, trunc, round, roundTo, sqrt, cbrt, exp, ln, log, log10, log2, pow, hypot, sign, min, max, clamp, trigonometry and hyperbolics, degToRad, radToDeg, pi, factorial, gcd, lcm, toNumber.
  • String — upper, lower, trim, trimStart, trimEnd, capitalize, reverse, concat, concatWs, contains, startsWith, endsWith, replace, indexOf, repeat, split, splitPart, substr, substring, left, right, padStart, padEnd, chr, ascii, toHex, toString, regexMatch, regexExtract, regexReplace.
  • Date — epochMs, epochToIso, yearOf, monthOf, dayOf, hourOf, minuteOf, dayOfWeekOf, dayOfYear (all UTC, over epoch milliseconds).
  • Array — arrayContains, first, last, arraySum, arrayMin, arrayMax, arrayReverse, arrayJoin, distinct.
  • Logic — coalesce, isNull, nullIf, isNumber, isString, isBoolean, length, typeOf.
  • Geo — 2D: haversineKm, bearingDeg, geoContains, geoWithin, geoIntersects (GeoJSON shapes, DE-9IM); 3D: distance3dM, altitudeBetween, geoWithinVolume, geodeticToEcef, ecefToGeodetic, groundStrike (WGS84, via geo3d).

Using it inside DataFusion

The sibling crate fv-value-datafusion translates a compiled Expr into a DataFusion expression wherever the semantics are provably identical — so a dialect filter pushes down to the source and runs vectorized — and falls back to a per-row UDF for the rest. The same conformance vectors are replayed through DataFusion to prove the two paths agree.

Conformance

The dialect's behaviour is defined by a published set of conformance vectors (in the fv-contract crate): 221 expressions with their expected values or errors. The test suite replays every one, and checks the catalogue against the contract's snapshot, so an implementation or validator in any language can be held to the same definition.

Develop

cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
cargo run --example custom_function
cargo run --example emit_catalogue
cargo bench
cargo publish --dry-run

CI runs all of the above plus a build on the declared minimum Rust version (1.85).

Versioning and license

Plain semver; tags vX.Y.Z at the published commit. A change that alters what an existing expression evaluates to is a major bump. See CHANGELOG.md. Apache-2.0, see LICENSE.