Goplasmatic.Datalogic

August 31, 2026 · View on GitHub

NuGet CI License: Apache 2.0

Part of datalogic-rs — one engine, every runtime.

.NET bindings for datalogic-rs, the JSONLogic rules engine with one Rust core and official bindings for Rust, Node.js, the browser (WASM), Python, Go, Java, .NET, and PHP. Same rules, same semantics: every binding runs the same core and passes the same 1,698-case conformance battery (59 suites). Compile once, evaluate many, natively in .NET.

For the cross-runtime overview and the API-tier model every binding implements, see the repo README.

New in v5. This package is new: there is no v4 .NET artifact. If you are coming from the v4 Rust crate or the v4 @goplasmatic/datalogic WASM package, the engine's v4 → v5 changes are catalogued in MIGRATION.md.

Install

dotnet add package Goplasmatic.Datalogic

The binding is a P/Invoke wrapper over the engine's C ABI, built on LibraryImport source-generated stubs, so the assembly is NativeAOT-ready out of the box. The NuGet package ships the native library under runtimes/<rid>/native/ for every supported platform; dotnet publish picks the right one for the target RID automatically. No Rust toolchain needed.

PlatformRIDs
Linuxlinux-x64, linux-arm64
macOSosx-x64, osx-arm64
Windowswin-x64, win-arm64

Targets net8.0 or newer.

Quick start

using Goplasmatic.Datalogic;

using var engine = new Engine();
var result = engine.Apply("""{"+":[1,2]}""", "{}");  // "3"

Rules, data, and results cross the boundary as JSON strings. The ApplyJson / EvaluateJson variants return a parsed System.Text.Json.Nodes.JsonNode instead of a string.

Compile once, evaluate many

Compile the rule once when you'll evaluate it against many data inputs:

using var engine = new Engine();
using var rule = engine.Compile("""{"var":"x"}""");
foreach (var x in new[] { 1, 2, 3 })
{
    Console.WriteLine(rule.Evaluate($"{{\"x\":{x}}}"));
}

Engine and compiled Rule objects are thread-safe: build and compile once, share them across threads. Sessions (below) are not.

Sessions (hot loops)

A Session reuses one arena across evaluations and resets it at the start of every call, so peak memory stays bounded:

using var session = engine.OpenSession();
foreach (var data in inputs)
{
    var result = session.Evaluate(rule, data);
}

Open one session per thread; a Session is not thread-safe. Every public type implements IDisposable (with a finalizer as best-effort fallback), so prefer using to release native handles deterministically.

Parsed data handles

When the same payload feeds many evaluations, parse it once into a DataHandle and skip the per-call JSON parse entirely:

using var data = DataHandle.Parse("""{"user":{"age":25,"plan":"pro"}}""");
var a = rule.Evaluate(data);            // thread-safe one-shot
var b = session.Evaluate(rule, data);   // session hot path

A DataHandle is immutable, thread-safe, and engine-independent — one handle can feed rules compiled by different engines, and evaluation never consumes it. Dispose it after the last evaluation that uses it.

Typed results

For predicate- and scalar-shaped rules, the typed session variants return the value directly with no JSON serialization on the native side. They take a DataHandle (the flows that want typed results are exactly the flows that parse data once):

bool   ok  = session.EvaluateBool(rule, data);    // strict JSON boolean
long   n   = session.EvaluateInt64(rule, data);   // exact integer
double x   = session.EvaluateDouble(rule, data);  // any JSON number
bool   t   = session.EvaluateTruthy(rule, data);  // JSONLogic truthiness

EvaluateBool / EvaluateInt64 / EvaluateDouble throw EvaluateException with Status == EvaluationStatus.TypeMismatch (error type "TypeMismatch") when the rule evaluates fine but the result is not of the requested type. EvaluateTruthy never mismatches — it collapses any result through the engine's configured truthiness rules (the same coercion if / and / or apply).

Batch evaluation

Two batch shapes cross the native boundary in a single call:

// One rule x N payloads:
EvaluationResult[] perPayload = session.EvaluateBatch(rule, dataHandles);

// N rules x one payload (the rule-set / feature-flag shape):
EvaluationResult[] perRule = session.EvaluateMany(rules, data);

Per-item failures don't throw: each EvaluationResult carries either the result (IsSuccess, Json) or the item's error detail (Status, ErrorTag, ErrorMessage, ErrorOperator). Value returns the JSON or throws the mapped exception for callers that treat any item failure as exceptional. The batch call itself only throws for argument-level problems (e.g. a rule compiled by a different engine).

API surface

The binding mirrors the Rust engine's API tier model. Rules and results cross the boundary as JSON strings; data crosses as a JSON string or a pre-parsed DataHandle, and the typed session variants return .NET scalars directly.

TierEntry pointUse when
One-shotengine.Apply(rule, data)Ad-hoc evaluation, one rule + one data shape
Engine + confignew Engine(templating) / Engine.Builder()…Build()Templating mode, custom operators, evaluation config
Compile onceengine.Compile(rule)rule.Evaluate(data)Same rule evaluated against many data inputs
Parse onceDataHandle.Parse(json)rule.Evaluate(dataHandle)Same payload evaluated by many rules / many times
Sessionengine.OpenSession()session.Evaluate(rule, data)Hot loops: amortise arena reset across iterations
Typedsession.EvaluateBool/Int64/Double/Truthy(rule, dataHandle)Predicates and scalars without JSON round-trips
Batchsession.EvaluateBatch(rule, datas) / session.EvaluateMany(rules, data)Many evaluations per native call
Tracedengine.OpenTracedSession()session.Evaluate(rule, data)Step-by-step debugging; feeds the React debugger

Custom operators

Register C#-implemented operators through the builder. Each callback receives the operator's pre-evaluated arguments as a JSON-array string and returns a JSON-value string; throwing signals an evaluation error whose message bubbles back to the caller.

using var engine = Engine.Builder()
    .AddOperator("double", argsJson =>
    {
        var n = System.Text.Json.Nodes.JsonNode.Parse(argsJson)![0]!.GetValue<double>();
        return (n * 2).ToString();
    })
    .Build();
Console.WriteLine(engine.Apply("""{"double":[21]}""", "{}"));  // "42"

Built-ins win: a custom registration of a built-in name (+, if, var, ...) never dispatches at evaluation time; the built-in always runs.

Engine configuration

Engine.Builder().SetConfigJson(json) sets the evaluation semantics from a JSON object string: an optional preset plus per-field overrides. Unknown keys or values throw EvaluateException (error type ConfigurationError), so typos fail loudly:

using var lenient = Engine.Builder()
    .SetConfigJson("""{"division_by_zero":"return_null"}""")
    .Build();
lenient.Apply("""{"/":[1.5,0]}""", "{}");  // "null"

using var strict = Engine.Builder()
    .SetConfigJson("""{"preset":"strict"}""")
    .Build();
strict.Apply("""{"+":["",1]}""", "{}");    // throws: strict rejects non-numeric coercion
KeyValues
preset"default", "safe_arithmetic", "strict"
arithmetic_nan_handling"throw_error", "ignore_value", "coerce_to_zero", "return_null"
division_by_zero"return_saturated", "throw_error", "return_null", "return_infinity"
loose_equality_errorsbool
truthy_evaluator"javascript", "python", "strict_boolean"
numeric_coercionobject of bools: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric
max_recursion_depthinteger >= 1

The preset applies first; the remaining keys override individual fields on top of it. Every binding shares this JSON schema and parses it with the same core code, so a config that works here works in the Python, Node, and WASM bindings too. The full semantics of each knob are documented on the Rust crate's EvaluationConfig.

Error handling

Everything the binding throws extends DatalogicException:

ExceptionWhen
ParseExceptionMalformed rule or data JSON, or an unsupported operator
EvaluateExceptionOperator failure at runtime, or a rejected engine config

The structured fields ride on the base class: ErrorType is the stable engine tag (e.g. "ParseError", "Thrown", "TypeMismatch", "InvalidArgument"), Operator the outermost failing operator (e.g. "+"), PathJson the root-to-leaf error path as a JSON array (each null when not applicable), and Status the coarse EvaluationStatus the native call returned (ParseError, EvaluationError, TypeMismatch, InvalidArgument, InternalError).

using var engine = new Engine();
try
{
    // arithmetic on a non-numeric string throws {"type":"NaN"}
    engine.Apply("""{"+":[{"var":"x"},1]}""", """{"x":"abc"}""");
}
catch (EvaluateException e)
{
    Console.WriteLine(e.Status);     // EvaluationError
    Console.WriteLine(e.ErrorType);  // "Thrown"
    Console.WriteLine(e.Operator);   // "+"
    Console.WriteLine(e.PathJson);   // JSON-array path through the compiled tree
}

Threading

TypePattern
EngineBuild once; share across threads
RuleCompile once; share across threads
DataHandleParse once; immutable, share across threads (and engines)
SessionOne per worker thread; never share

TracedSession is thread-safe as well. Rules passed to a session must come from the engine that opened it (a foreign rule throws EvaluateException with Status == EvaluationStatus.InvalidArgument).

Tracing

using var session = engine.OpenTracedSession();
var run = session.Evaluate("""{"+":[{"var":"x"},1]}""", """{"x":41}""");
Console.WriteLine(run.Result);          // 42
Console.WriteLine(run.Steps.Count);     // number of executed nodes

Same trace envelope as every other binding; the React debugger consumes it directly. TracedRun exposes Result, ExpressionTree, Steps, Error, and StructuredError (plus IsSuccess); runtime failures surface inside the run rather than as exceptions. Tracing disables the optimizer so every operator appears in the trace: use it for debugging, not hot paths.

Performance

Geomean across 51 operator benchmark suites (Apple M2 Pro, median of 3 runs; pairwise shared-suite ratios per the methodology): the native Rust core evaluates at 10.3 ns/op, 7.0× faster than json-logic-engine (compiled, the fastest JS engine), 28.1× faster than jsonlogic-rs (the closest Rust alternative), and 83.6× faster than the json-logic-js reference implementation. The WASM build under Node measures 900.5 ns geomean (88× native); on Node servers, prefer @goplasmatic/datalogic-node.

The P/Invoke boundary adds a small per-call marshalling cost on top of the core numbers.

Building from source

The binding lives in bindings/dotnet/. At runtime the native library resolves in order: the DATALOGIC_NATIVE_LIB env var (absolute path), NuGet's runtimes/<rid>/native/ layout, then the in-tree C ABI target dir. On first use the binding asserts the resolved library speaks C ABI v2 (datalogic_abi_version() == 2) and fails loudly with a rebuild hint if a stale library is picked up. So a fresh clone needs the C ABI built once:

git clone https://github.com/GoPlasmatic/datalogic-rs
cd datalogic-rs/bindings/c && cargo build --release
cd ../dotnet
dotnet build
dotnet test

Learn more

License

Apache-2.0. See the main repository for source and contribution guidelines.