datalogic-rs
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
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 = eval_str.unwrap;
assert_eq!;
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 ;
// JSON string in, JSON string out
let s = eval_str.unwrap;
assert_eq!;
// JSON string in, OwnedDataValue out
let v = eval.unwrap;
assert_eq!;
// Compile once at the module level when the rule is fixed
let logic = compile.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 = eval_into.unwrap;
assert_eq!;
Tier 1: Engine one-shot
Construct an Engine when you need anything beyond defaults: custom
operators, a non-default EvaluationConfig, or templating mode.
use Engine;
let engine = new;
let result = engine.eval_str.unwrap;
assert_eq!;
Use Engine::builder() to register operators and tweak behaviour:
use ;
let engine = builder
.with_config
.with_templating // 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 Engine;
let engine = new;
let compiled = engine.compile.unwrap;
let mut session = engine.session;
for x in 0..1_000
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 Arc;
use ;
let engine = new;
let logic = engine.compile.unwrap;
let mut session = new;
let handle = spawn;
assert_eq!;
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 Bump;
use Engine;
let engine = new;
let compiled = engine.compile.unwrap;
let arena = new;
let result = engine.evaluate.unwrap;
assert_eq!;
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 Engine;
let engine = new;
let traced = engine.trace.eval_str;
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 ;
use json;
let data = json!;
let metadata = json!;
let engine = new;
let rule = engine
.compile
.unwrap;
let roots = from;
assert_eq!;
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 Engine;
let engine = new;
let compiled = engine.compile.unwrap;
let mut session = engine.session;
let result = session.eval_borrowed.unwrap;
assert_eq!;
// Other accessors: .as_f64(), .as_str(), .as_bool(), .as_array(), .as_object().
Conversion to other shapes:
- To a JSON string:
value.to_string()(DataValueandOwnedDataValueboth implementDisplay). - To
serde_json::Value(requiresserde_json): useeval_into::<serde_json::Value>(...). - To a typed Rust struct (requires
serde_json): useeval_into::<T>(...)whereT: DeserializeOwned. - Owned vs borrowed:
DataValue<'a>borrows from aBump;OwnedDataValueis 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 Bump;
use ;
;
let engine = builder.add_operator.build;
let result = engine.eval_str.unwrap;
assert_eq!;
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 Engine;
let engine = new;
let names: = engine.builtin_operator_names.collect;
assert!;
assert!; // alias of `val`
assert_eq!;
let map = engine.operators.find.unwrap;
assert_eq!;
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 ;
let engine = new;
let diags = engine.check;
assert_eq!;
assert_eq!;
assert!;
let rule = engine.compile.unwrap;
let facts = rule.facts;
let reads: = facts.reads.iter.map.collect;
assert_eq!;
assert!;
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): avarthat finds nothing isnull(the default) or aVariableNotFounderror - 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 Engine;
let engine = builder.with_templating.build;
let result = engine.eval_str.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 Engine;
let engine = builder
.with_templating
.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 matchingcode() -> ErrorCode: the kind without its payload (Copy,Eq,Hash), every variant present in every build;tag()is its nameoperator() -> Option<&str>: the innermost failing operator, custom operators includednode_ids() -> &[u32]: breadcrumbs from the compiled tree (leaf to root); resolve to a JSON path viaError::resolve_path(&logic), which returns aVec<PathStep>you can print or serialisethrown_value() -> Option<&OwnedDataValue>: the payload of athrow, or the structured value the engine threw (division by zero under the default config throws{"type": "NaN"})
use ;
let engine = new;
let err = engine.eval_str.unwrap_err;
assert!;
assert_eq!;
assert_eq!;
assert_eq!;
// 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):
[]
= { = "5", = ["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 implicitflagKey + targetingKeyfrom the root$flagdenvelope) plus(hash * total_weight) >> 32integer 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 leadingv/V, pad partial versions (1.0→1.0.0), coerce numeric input to string, and drop SemVer build metadata. Backed by thesemvercrate (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 Engine;
let engine = new;
// A typical flagd targeting rule: ship "new-ui" to 50 % of @example.com users.
let result: String = engine.eval_str?;
// result is one of "\"new-ui\"" or "\"old-ui\"", sticky per email.
# Ok::
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 ;
use Bump;
// Engine-wide: every evaluation is bounded.
let engine = builder
.with_config
.build;
// Per call, and reporting what was spent.
let compiled = engine.compile?;
let arena = new;
let Metered =
engine.evaluate_metered?;
# Ok::
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
- Repo README: cross-runtime overview, per-binding READMEs
- Documentation site: long-form guide, operator reference, advanced topics
- Online playground: try rules live in the visual debugger
docs.rs/datalogic-rs: Rust API referenceexamples/README.md: index of runnable examplestests/README.md: JSONLogic suite format
License
Apache 2.0. See LICENSE.