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](https://img.shields.io/crates/v/fv-value.svg)](https://crates.io/crates/fv-value)
[![docs.rs](https://docs.rs/fv-value/badge.svg)](https://docs.rs/fv-value)
[![ci](https://github.com/FusionVault/value/actions/workflows/ci.yml/badge.svg)](https://github.com/FusionVault/value/actions/workflows/ci.yml)

A small expression dialect for computing values over rows.

```text
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

```bash
cargo add fv-value
```

## Quick start

```rust
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`:

```rust
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:

```rust
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

```rust
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]`:

```rust
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.

```rust
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:

```rust
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`]https://crates.io/crates/geo3d).

## Using it inside DataFusion

The sibling crate [`fv-value-datafusion`](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`](https://crates.io/crates/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

```bash
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](CHANGELOG.md). Apache-2.0, see
[LICENSE](LICENSE).