datalogic-rs 5.8.1

High-performance JSONLogic (json-logic) rules engine and sandboxed expression evaluator in Rust — one core, official bindings for Node.js, WASM, Python, Go, Java, .NET, and PHP. Compile once, evaluate in nanoseconds.
Documentation

datalogic-rs

Crates.io Documentation Rust 1.98+ License: Apache 2.0

A fast, type-safe Rust implementation of JSONLogic for evaluating logical rules as JSON. Compile a rule once, then evaluate it millions of times across threads. The same engine serves as a rule engine, a JSON template engine, or a safe expression evaluator.

This is the Rust core of the datalogic-rs monorepo. The repo also ships official bindings for Node.js, the browser (WASM), Python, Go, Java, .NET, and PHP, plus a React visual debugger: same rules, same semantics. For the cross-runtime overview and the per-binding READMEs, see the repo README.

Install

cargo add datalogic-rs

The default build is serde_json-free and ships only the JSONLogic baseline operators. Opt in to feature flags as needed; see the feature flag reference below.

Hello, JSONLogic

let result = datalogic_rs::eval_str(r#"{"+": [1, 2, 3]}"#, r#"{}"#).unwrap();
assert_eq!(result, "6");

eval_str parses the rule, parses the data, evaluates, and hands you back a JSON string. The free functions on the crate root wrap a shared default Engine; build your own to add custom operators, change config, or amortise compilation. The rest of this README covers when to use which API.

Choosing your API: five tiers, one engine

The crate exposes five evaluation tiers in increasing order of control. Pick by use case: most callers want Tier 0 for ad-hoc work or Tier 2 for repeated evaluation.

Tier Entry point Arena owner Returns Use when
0 datalogic_rs::eval_str / eval / eval_into / compile lazy static Engine String / OwnedDataValue / T / Logic One-shot scripts, ad-hoc evaluation, no custom config
1 Engine::eval_str / eval / eval_into per-call Bump String / OwnedDataValue / T You need custom operators, config, or templating mode
2 Engine::session() → Session::eval* session-owned Bump owned or &DataValue<'a> borrow Hot loops, services, batch jobs
3 Engine::evaluate(&Logic, data, &Bump) caller-owned Bump &'a DataValue<'a> Zero-copy result pipelines, custom pool strategies
4 Engine::trace() → TracedSession::* per-call Bump + trace buffer TracedRun<R> (result + steps) Debugging, visualisation, instrumentation

Tier 0: Module-level one-shot

The free functions wrap a static default Engine. No construction, no configuration. Three result shapes:

use datalogic_rs::{compile, eval, eval_str};

// JSON string in, JSON string out
let s = eval_str(r#"{">": [{"var": "x"}, 10]}"#, r#"{"x": 42}"#).unwrap();
assert_eq!(s, "true");

// JSON string in, OwnedDataValue out
let v = eval(r#"{"+": [1, 2]}"#, r#"{}"#).unwrap();
assert_eq!(v.as_i64(), Some(3));

// Compile once at the module level when the rule is fixed
let logic = compile(r#"{"==": [{"var": "status"}, "active"]}"#).unwrap();

With the serde_json feature, eval_into::<T> returns any T: DeserializeOwned:

// Cargo.toml: datalogic-rs = { version = "5", features = ["serde_json"] }
let n: i64 = datalogic_rs::eval_into(r#"{"+": [1, 2, 3]}"#, r#"{}"#).unwrap();
assert_eq!(n, 6);

Tier 1: Engine one-shot

Construct an Engine when you need anything beyond defaults: custom operators, a non-default EvaluationConfig, or templating mode.

use datalogic_rs::Engine;

let engine = Engine::new();
let result = engine.eval_str(
    r#"{"==": [{"var": "status"}, "active"]}"#,
    r#"{"status": "active"}"#,
).unwrap();
assert_eq!(result, "true");

Use Engine::builder() to register operators and tweak behaviour:

use datalogic_rs::{Engine, EvaluationConfig};

let engine = Engine::builder()
    .with_config(EvaluationConfig::safe_arithmetic())
    .with_templating(true)              // requires `templating` feature
    .with_template_key_escape('$')      // optional: emit operator-named keys
    .build();

Tier 2: Session (the right default for repeated evaluation)

Session owns a reusable bumpalo::Bump; call reset() between iterations so peak memory tracks the largest single evaluation, not the sum. The session never resets on its own.

use datalogic_rs::Engine;

let engine = Engine::new();
let compiled = engine.compile(r#"{"+": [{"var": "x"}, 1]}"#).unwrap();
let mut session = engine.session();

for x in 0..1_000 {
    let payload = format!(r#"{{"x": {x}}}"#);
    let result = session.eval_str(&compiled, &payload).unwrap();
    // ...consume `result`...
    session.reset();          // O(1); keeps chunks for the next iteration
}

Session::eval_borrowed returns a &'a DataValue<'a> borrow into the session's own arena, which skips the owned deep-clone when you consume the result before the next session call. For pre-sizing the arena after a warm-up pass, use session.allocated_bytes() + session.reset_with_capacity(bytes).

Tokio idiom: Arc<Engine> shared across worker threads (it's Send + Sync), one session per task (Send but !Sync). A SharedSession holds the engine by Arc instead of borrowing it, so it is 'static + Send: store it in a struct, move it into a spawned task, or hold it across .await points.

use std::sync::Arc;
use datalogic_rs::{Engine, SharedSession};

let engine = Arc::new(Engine::new());
let logic = engine.compile(r#"{"var": "x"}"#).unwrap();
let mut session = SharedSession::new(engine);
let handle = std::thread::spawn(move || session.eval_str(&logic, r#"{"x": 1}"#).unwrap());
assert_eq!(handle.join().unwrap(), "1");

Full pattern: examples/compile_once_evaluate_many.rs.

Tier 3: Zero-copy evaluate(&Bump)

When the result borrow can stay scoped to a caller-managed arena, call Engine::evaluate and skip the owned deep-clone. The caller owns the Bump; the library never resets it.

use bumpalo::Bump;
use datalogic_rs::Engine;

let engine = Engine::new();
let compiled = engine.compile(r#"{"==": [{"var": "status"}, "active"]}"#).unwrap();

let arena = Bump::new();
let result = engine.evaluate(&compiled, r#"{"status": "active"}"#, &arena).unwrap();
assert_eq!(result.as_bool(), Some(true));

Reach for this tier when you have a pool-managed arena, are pipelining values across stages without crossing the value boundary, or want to decide exactly when memory is reclaimed.

Tier 4: Traced evaluation (trace feature)

Enable the trace feature, then ask the engine for a TracedSession. Each call records the expression tree + per-node execution steps.

// Cargo.toml: datalogic-rs = { version = "5", features = ["trace"] }
use datalogic_rs::Engine;

let engine = Engine::new();
let traced = engine.trace().eval_str(
    r#"{"and": [true, {"var": "x"}]}"#,
    r#"{"x": true}"#,
);
let result_string = traced.result.unwrap();
// `traced.steps` is the per-node execution trace
// (drop into the React debugger or process programmatically).

Full pattern: examples/tracing.rs.

Input shapes

Engine::evaluate and Session::eval_borrowed accept any input the caller is likely to have on hand, via the sealed EvalInput trait. Per-call cost differs:

Shape Cost per call
&str (JSON literal) parse into the arena; unescaped strings borrowed
&serde_json::Value (serde_json feature) spines built in the arena, strings and keys borrowed
&OwnedDataValue spines built in the arena, leaves borrowed
DataValue<'a> (by value) one arena alloc for the top node
&'a DataValue<'a> (by reference) zero: pass-through
&ParsedData (parse once via ParsedData::from_json) zero: same as the pre-parsed row
Roots / &Roots (several values as one object) one object node, plus each root's own cost

For the same-input-many-rules case, or when upstream stages already produced an arena value, prefer the &'a DataValue<'a> path, which allocates nothing. ParsedData is the owned form of that pattern: it parses a JSON payload once into its own arena and hands out &DataValue borrows for as long as the handle lives. The bindings expose it as their DataHandle tier.

On the engine that compiled the rule, an owned, serde_json or Roots input is brought in only along the paths the rule reads, when Logic::facts() reports those reads complete.

Roots evaluates separate values as the fields of one top-level object, without copying them into a combined value:

use datalogic_rs::{Engine, Roots};
use serde_json::json;

let data = json!({"user": {"name": "ana"}});
let metadata = json!({"channel": "web"});

let engine = Engine::new();
let rule = engine
    .compile(r#"{"cat": [{"var": "data.user.name"}, "@", {"var": "metadata.channel"}]}"#)
    .unwrap();
let roots = Roots::from([("data", &data), ("metadata", &metadata)]);
assert_eq!(engine.session().eval_str(&rule, &roots).unwrap(), r#""ana@web""#);

The Tier 0 / Tier 1 one-shot methods (eval, eval_str, eval_into) accept a similar set via the OwnedInput trait, which omits ParsedData and the DataValue<'a> shapes (there is no caller arena to borrow from).

Runnable example: examples/zero_copy_input.rs.

Working with DataValue

Evaluation returns &'a DataValue<'a>, an arena-allocated, borrowed JSON-shaped value tree. The type lives in the sibling datavalue crate (re-exported at the root and as datalogic_rs::datavalue). Most callers only need a handful of accessors:

use datalogic_rs::Engine;

let engine = Engine::new();
let compiled = engine.compile(r#"{"var": "user.score"}"#).unwrap();
let mut session = engine.session();
let result = session.eval_borrowed(&compiled, r#"{"user": {"score": 42}}"#).unwrap();

assert_eq!(result.as_i64(), Some(42));
// Other accessors: .as_f64(), .as_str(), .as_bool(), .as_array(), .as_object().

Conversion to other shapes:

  • To a JSON string: value.to_string() (DataValue and OwnedDataValue both implement Display).
  • To serde_json::Value (requires serde_json): use eval_into::<serde_json::Value>(...).
  • To a typed Rust struct (requires serde_json): use eval_into::<T>(...) where T: DeserializeOwned.
  • Owned vs borrowed: DataValue<'a> borrows from a Bump; OwnedDataValue is the heap-owned counterpart for crossing arena lifetimes. Convert via .to_owned() (borrowed → owned) and .to_arena(&bump) (owned → borrowed).

Public types at a glance

Type Role
Engine Immutable evaluation engine; entry point for every tier
EngineBuilder Builder for engines with custom config, operators, modes
Logic Compiled, thread-safe rule snapshot
Session Arena-reusing handle for hot loops; caller resets
SharedSession Session that holds the engine by Arc; 'static + Send
Roots Several values evaluated as one top-level object
ParsedData Self-contained parsed payload; evaluate many rules against one parse
DataValue Arena-borrowed JSON-shaped value (returned from evaluate)
OwnedDataValue Heap-owned counterpart of DataValue (via datavalue)
EvaluationConfig Behaviour knobs: NaN, division by zero, truthiness, coercion
CustomOperator Trait you implement to extend the engine
CustomOperatorInfo What a custom operator declares about itself (CustomOperator::info)
operator::EvalContext Opaque engine context passed to CustomOperator::evaluate
Family An operator family, for EngineBuilder::with_families
Error / ErrorKind Unified error type with operator + node-id breadcrumbs
ErrorCode An error's kind without its payload, for matching
Diagnostic / CompileError Problems Engine::check finds; what compile_checked returns
Facts / DataPath What a compiled rule reads and calls (Logic::facts)
OperatorInfo One built-in operator, as Engine::operators() describes it
TracedRun / TracedSession Tracing types (trace feature)

Custom operators

Register custom operators on Engine::builder() and call them from rules the same way as the built-ins. Args arrive pre-evaluated as arena-resident &DataValue<'a> borrows; you allocate the result back into the arena.

use bumpalo::Bump;
use datalogic_rs::{CustomOperator, DataValue, Engine, Result, operator::EvalContext};

struct Double;
impl CustomOperator for Double {
    fn evaluate<'a>(
        &self,
        args: &[&'a DataValue<'a>],
        _ctx: &mut EvalContext<'_, 'a>,
        arena: &'a Bump,
    ) -> Result<&'a DataValue<'a>> {
        let n = args.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
        Ok(arena.alloc(DataValue::from_f64(n * 2.0)))
    }
}

let engine = Engine::builder().add_operator("double", Double).build();
let result = engine.eval_str(r#"{"double": 21}"#, "null").unwrap();
assert_eq!(result, "42");

Runnable example: examples/custom_operator.rs. Full guide: Custom Operators.

The CustomOperator trait is the main extension point and is stable for the 5.x series: no required-method additions, no signature changes. Two default methods let an operator say more: info() declares whether it is deterministic, whether it reads the data context and how many arguments it takes (so the engine can fold it, trust it in Logic::facts(), and reject a bad call before its arguments run), and check() validates a call's arguments for Engine::check.

Register with try_add_operator instead of add_operator to refuse a name a built-in already answers to (if, var, ?:), where the custom operator would never run. Arc<T> implements CustomOperator, so one instance can serve several engines, and Engine::to_builder() starts a new builder from a running engine with its operators and settings.

Introspection

engine.builtin_operator_names() iterates every built-in key this engine resolves (canonical name first, then its aliases such as var, ?:, and match), reflecting the compiled feature set and the engine's operator families. engine.custom_operator_names() lists the registered custom operators and engine.has_custom_operator(name) checks a single name. Together, the built-in and custom names are the engine's full vocabulary, which authoring tools need under templating mode, where an unknown key is data rather than an error. A custom operator registered under a built-in name is never reached: built-in resolution wins at compile time. engine.operators() describes each built-in operator (family, argument counts, effect, cost).

use datalogic_rs::Engine;

let engine = Engine::new();
let names: Vec<&str> = engine.builtin_operator_names().collect();
assert!(names.contains(&"val"));
assert!(names.contains(&"var")); // alias of `val`
assert_eq!(engine.custom_operator_names().count(), 0);

let map = engine.operators().find(|op| op.name == "map").unwrap();
assert_eq!((map.min_args, map.max_args), (2, Some(2)));

Rule analysis

Engine::check reports every problem the engine can see in a rule before it runs, each with a JSON Pointer into the rule, and Engine::compile_checked refuses a rule with any error. Logic::facts() reports the data paths a compiled rule reads and the operators it calls:

use datalogic_rs::{CheckMode, DiagnosticCode, Engine};

let engine = Engine::new();

let diags = engine.check(r#"{"if": [{"vr": "age"}, "adult", "minor"]}"#, CheckMode::Engine);
assert_eq!(diags[0].code, DiagnosticCode::UnknownOperator);
assert_eq!(diags[0].pointer, "/if/0");
assert!(diags[0].message.contains("did you mean `var`"));

let rule = engine.compile(r#"{">=": [{"var": "user.age"}, 18]}"#).unwrap();
let facts = rule.facts();
let reads: Vec<String> = facts.reads().iter().map(|p| p.to_string()).collect();
assert_eq!(reads, ["user.age"]);
assert!(facts.reads_complete() && facts.is_deterministic());

Full guide: Rule Analysis.

Configuration

EvaluationConfig controls edge-case behaviour:

  • NaN handling (NanHandling): what happens when arithmetic receives a non-number
  • Division by zero (DivisionByZeroHandling)
  • Truthiness (TruthyEvaluator): JavaScript, Python, strict boolean, or a custom closure
  • Numeric coercion (NumericCoercionConfig): null-to-zero, bool-to-number, empty-string-to-zero, etc.
  • Missing variables (MissingVar): a var that finds nothing is null (the default) or a VariableNotFound error
  • Recursion depth: caps how many evaluations run at once on one thread, which bounds custom operators that re-enter the engine

Presets like EvaluationConfig::safe_arithmetic() and EvaluationConfig::strict() cover common postures. On the builder, with_families([Family::ExtString, ...]) keeps an engine to the JSONLogic core plus the operator families you name. See the Configuration guide and the runnable examples/configuration.rs.

Templating mode

With Engine::builder().with_templating(true) (requires the templating feature), multi-key objects in a compiled rule become output-shaping templates: keys flow through to the output and operator values become computed fields.

// Cargo.toml: datalogic-rs = { version = "5", features = ["templating"] }
use datalogic_rs::Engine;

let engine = Engine::builder().with_templating(true).build();
let result = engine.eval_str(
    r#"{"greeting": {"cat": ["Hello ", {"var": "name"}]},
        "isAdult": {">=": [{"var": "age"}, 18]}}"#,
    r#"{"name": "Jane", "age": 25}"#,
).unwrap();
// {"greeting":"Hello Jane","isAdult":true}

A single-key object is an operator invocation, so a key that names a built-in (type, map, if, length, …) or a registered custom operator cannot be an output field. An escape prefix recovers it:

use datalogic_rs::Engine;

let engine = Engine::builder()
    .with_templating(true)
    .with_template_key_escape('$')
    .build();

// {"$type": {"var": "x"}}  ->  {"type": 1}
// {"$$type": 1}            ->  {"$type": 1}   (doubling escapes the sigil)

It is unset by default, so $-prefixed keys otherwise pass through verbatim. The prefix is a char, so payloads that already use $ keys can pick ~ or #.

engine.compile_template(rule) and engine.compile_strict(rule) choose the mode for one compile, so one engine with one set of custom operators can compile both conditions and output templates. There is no preserve operator: literal scalars and arrays work inline, and templated objects belong in templating mode. Runnable example: examples/structured_objects.rs.

Error model

Every fallible path returns Result<T, Error>. Error is #[non_exhaustive]; it exposes one public field and reads the rest through accessors:

  • kind: ErrorKind: the discriminant (ParseError, Thrown, InvalidArguments, TypeError, ArithmeticError, Custom, …), public for pattern matching
  • code() -> ErrorCode: the kind without its payload (Copy, Eq, Hash), every variant present in every build; tag() is its name
  • operator() -> Option<&str>: the innermost failing operator, custom operators included
  • node_ids() -> &[u32]: breadcrumbs from the compiled tree (leaf to root); resolve to a JSON path via Error::resolve_path(&logic), which returns a Vec<PathStep> you can print or serialise
  • thrown_value() -> Option<&OwnedDataValue>: the payload of a throw, or the structured value the engine threw (division by zero under the default config throws {"type": "NaN"})
use datalogic_rs::{Engine, ErrorCode, ErrorKind};

let engine = Engine::new();
let err = engine.eval_str(r#"{"/": [1, 0]}"#, r#"{}"#).unwrap_err();
assert!(matches!(err.kind, ErrorKind::Thrown(_)));
assert_eq!(err.code(), ErrorCode::Thrown);
assert_eq!(err.operator(), Some("/"));
assert_eq!(err.thrown_value().map(|v| v.to_string()), Some(r#"{"type":"NaN"}"#.to_string()));

// By default a missing variable is not an error: `{"var": "missing"}`
// evaluates to null. Use `exists`, `missing`, or `??` to detect it, or
// configure `MissingVar::Error` to make it a `VariableNotFound` error.

Runnable example: examples/error_handling.rs.

Thread safety

Engine, Logic, and your CustomOperator types are all Send + Sync: construct the Engine once, compile the rule once, and share both via Arc across as many threads or tokio tasks as you want. Session is the per-task workhorse: each task opens its own. It owns a bumpalo::Bump that can't be shared across threads (the same way a database connection is per-task in a connection-pool model), and Rust enforces this at compile time, so there's no runtime hazard.

Type Pattern
Engine One per process; share via Arc
Logic Compile once; share via Arc (or use Engine::compile_arc)
CustomOperator implementors Register on the builder; live inside the shared Engine (Send + Sync bound)
Session One per task / per thread (the per-task workhorse)
SharedSession A Session holding Arc<Engine>: store it in a struct or move it to a task

Runnable example: examples/thread_safety.rs.

Feature flags

Feature Effect
serde_json &serde_json::Value interop and eval_into::<T> typed deserialisation
templating Structure-preservation (templating) mode
datetime Date / time operators (pulls in chrono + chrono-tz for IANA-zone args)
trace Execution-step recording for the debugger (implies serde_json)
error-handling try / throw operators
ext-string, ext-array, ext-object, ext-control, ext-math Optional operator families
flagd flagd-compat operators (fractional, sem_ver); pulls in semver
wasm-clock JS-host clock for now on wasm32-unknown-unknown (chrono/wasmbind). Enable only when a JS host runs the module; never for wasmtime / wazero / Chicory (issue #47). Without it now traps on that target
tensor The Tensor value plus 20 marshalling-only operators over it (JSON to a model's inputs and back). No new dependency
tensor-half Lifts the f16 / bf16 restriction on the element-wise tensor operators; pulls in half through datavalue
budget Per-evaluation operation counter with a hard abort: EvaluationConfig::ops_budget, Engine::evaluate_metered, Session::eval_metered; makes EvalContext::charge count and raises ErrorKind::BudgetExceeded (the variant exists in every build)
all-operators Every operator family at once: datetime, error-handling, the five ext-* families, flagd and tensor. A family added in a later release joins it, so depending on it keeps the full operator set. Not included: serde_json, templating, trace, budget, tensor-half, wasm-clock

The default build is serde_json-free; opt in via features = ["serde_json"] when you need the value boundary. For every operator plus the value boundary and templating, the set the official bindings use (less trace and budget):

[dependencies]
datalogic-rs = { version = "5", features = ["all-operators", "serde_json", "templating"] }

flagd: OpenFeature flagd-compatible operators

Enables two operators specified by the OpenFeature flagd in-process provider, implemented to match the canonical Go evaluator byte-for-byte:

  • fractional: deterministic percentage bucketing for A/B tests and rollouts. Uses MurmurHash3 x86-32 of a bucketing key (explicit string, or implicit flagKey + targetingKey from the root $flagd envelope) plus (hash * total_weight) >> 32 integer distribution, identical to the Go evaluator's algorithm. The crate vendors the hash inline (~30 LOC, no external dep) for portability across every target.
  • sem_ver: semantic-version comparison with the spec's four input normalizations: strip leading v/V, pad partial versions (1.0 → 1.0.0), coerce numeric input to string, and drop SemVer build metadata. Backed by the semver crate (optional dep). Operators: =, !=, <, <=, >, >=, ^ (same major), ~ (same major+minor).

Both operators return null on malformed input rather than raising. The flagd evaluator observes the null and falls back to the flag's default variant; non-flagd callers can compose with ?? or if for the same effect.

// Cargo.toml: datalogic-rs = { version = "5", features = ["flagd"] }
use datalogic_rs::Engine;

let engine = Engine::new();

// A typical flagd targeting rule: ship "new-ui" to 50 % of @example.com users.
let result: String = engine.eval_str(
    r#"{
        "fractional": [
            { "cat": [{ "var": "$flagd.flagKey" }, { "var": "email" }] },
            ["new-ui", 50],
            ["old-ui", 50]
        ]
    }"#,
    r#"{"email": "alice@example.com", "$flagd": {"flagKey": "header-color"}}"#,
)?;
// result is one of "\"new-ui\"" or "\"old-ui\"", sticky per email.
# Ok::<(), datalogic_rs::Error>(())

Conformance test suites under tests/suites/flagd/ mirror the canonical Go test files in open-feature/flagd so every release is checked against the upstream behaviour.

budget: bounding the work a rule does

Untrusted rules need a bound that is not a wall-clock timeout: one that is the same on every machine, and that refuses the work rather than reporting it afterwards. The budget feature adds a per-evaluation operation counter with a hard abort.

// Cargo.toml: datalogic-rs = { version = "5", features = ["budget"] }
use datalogic_rs::{Engine, EvaluationConfig, Metered};
use bumpalo::Bump;

// Engine-wide: every evaluation is bounded.
let engine = Engine::builder()
    .with_config(EvaluationConfig::default().with_ops_budget(Some(100_000)))
    .build();

// Per call, and reporting what was spent.
let compiled = engine.compile(r#"{"map": [{"var": "xs"}, {"*": [{"var": ""}, 2]}]}"#)?;
let arena = Bump::new();
let Metered { value, ops } =
    engine.evaluate_metered(&compiled, r#"{"xs": [1, 2, 3]}"#, &arena, u64::MAX)?;
# Ok::<(), datalogic_rs::Error>(())

One operation is one dispatched node, one item an iterator examines, or whatever an operator charges for the data it moves. The tensor family prices itself in elements, and a CustomOperator can do the same through EvalContext::charge. Literals and constant-folded subtrees cost nothing. Crossing the ceiling raises ErrorKind::BudgetExceeded { budget, spent } before the work is done, decorated with the node breadcrumb like every other error; try observes it but cannot recover from it.

Full guide, including how to pick a number and what the count does not promise across versions: Operation Budget.

Performance

Compiled rules dispatch through a single OpCode enum (no string lookups), values live in a bumpalo::Bump arena (no per-result heap allocation), and read-through operators like var borrow zero-copy from the caller's input. Geomean 10.3 ns/op across 51 operator suites on Apple M2 Pro; see the cross-library comparison in tools/benchmark/BENCHMARK.md.

Migrating from v4

v5 broke the 4.x API with no compat feature and no deprecated method shims. Headline renames: DataLogic → Engine, evaluate_json → eval_str / eval_into::<T>, Operator → CustomOperator, with_config(...) → Engine::builder().with_config(...).build(). See MIGRATION.md for the full v4 → v5 cookbook and CHANGELOG.md for the chronological breakage list.

Learn more

License

Apache 2.0. See LICENSE.