Skip to content

v3.0.0

Choose a tag to compare

@windischb windischb released this 17 Apr 12:06
· 49 commits to develop since this release

Cocoar.JsEval v3.0.0

JavaScript → real .NET Expression Trees — any LINQ provider, byte-identical SQL.

::: info From v2.0.0?
v2.0.0 was released and unlisted from NuGet within the same day after first-adopter integration surfaced an API friction point (see Migration from v2.0.0). Install v3.0.0 directly. v3.0.0 includes everything from v2.0.0 plus the API cleanup — no feature regressions.
:::

v3.0.0 introduces Cocoar.JsEval.Linq, which turns JS/TS arrow functions into real Expression<Func<T, TResult>> trees that Marten, EF Core, LINQ2DB (or any other IQueryable<T> provider) translates to native SQL — with the same output as if you had written the predicate in C# yourself.

Headline Feature: JS → LINQ

// Expose any IQueryable<T> to JS:
users.where(u => u.Name.startsWith('A') && u.IsActive)
     .orderBy(u => u.Name)
     .thenByDescending(u => u.Age)
// → SQL: WHERE name LIKE 'A%' AND is_active ORDER BY name ASC, age DESC

// Prefer C# LINQ-style names? Same Expression tree, same SQL:
users.where(u => u.Name.StartsWith('A') && u.IsActive)
     .orderBy(u => u.Name)
     .thenByDescending(u => u.Age)

// Relative dates inline — no host closure needed:
todos.where(t => t.DueDate < linq.today().AddDays(7))

Verified against three providers in sandbox tests — Marten (PostgreSQL/JSONB), EF Core (SQLite), LINQ2DB (SQLite) — the emitted SQL is byte-identical to what the C# compiler produces for the equivalent source lambda.

Highlights

  • Cocoar.JsEval.Linq — JS arrow function → Expression<Func<T, TResult>>
  • JsLinqExtensions — Where / Find / Count / Any / OrderBy / OrderByDescending / ThenBy / ThenByDescending directly on IQueryable<T>
  • Dual naming — JS and C# LINQ both work. The translator accepts u.Tags.some(...) and u.Tags.Any(...); str.includes(x) and str.Contains(x); str.toLowerCase() and str.ToLower(). Both styles produce byte-identical Expression trees — pick whichever fits your background
  • TypeScript declaration merging via embedded cocoar-jseval-linq.d.ts — grab it with LinqTypeScriptDefinition.WriteTo(path) so Monaco IntelliSense knows both name sets
  • linq.* typed literal DSL — linq.decimal('99.99'), linq.guid('…'), linq.date('2024-01-01'), linq.dateUtc, linq.dateOffset, linq.dateOnly, linq.timeOnly, linq.timeSpan, linq.int, linq.long, linq.double. Translator intercepts at AST level so precision is preserved
  • linq.today() / linq.now() / linq.utcNow() — zero-arg helpers that capture the current date/time at translation time. Compose with AddDays/AddHours for relative predicates: todos.where(t => t.DueDate < linq.today().AddDays(7))
  • Enum coercion — u.Status === 'Active' and u.Status === 1 both just work: the translator detects the enum property and converts the literal into a typed enum constant (string matching is case-insensitive). Output is the ORM-neutral native form — no implicit Convert(enum, Int32) wrapper — so both int-stored and string-stored enums translate correctly
  • CsDateTime — fluent date arithmetic in JS: cutoff.AddDays(7).AddHours(3), with implicit operators so comparisons against DateTime columns work transparently
  • Property Dependency Collector — ExpressionDependencyCollector.Collect(expr) returns the set of properties a predicate touches. Essential for reactive re-evaluation, cache invalidation, change-triggered queries
  • Pluggable IJsMethodMap — extend the JS-to-.NET method translation with your own mappings
  • ReflectionCache — ~65% faster + 74% fewer allocations on nested-lambda predicates for hot loops (ABAC rule engines, bulk filtering, …)
  • JsEvalBuilder.RegisterEngineConfigurator — generic extension point for add-on packages
  • JsEngine.UnderlyingEngine & JsEngine.EvaluateExpression(string): JsValue — direct access to the Jint engine and a value-returning evaluation path for advanced integrations (e.g. custom LINQ wiring without reflection hacks)

Quick Start

dotnet add package Cocoar.JsEval.Engine
dotnet add package Cocoar.JsEval.Linq
services.AddJsEval(b => b
    .AddLinq()            // registers JsLinqExtensions + linq.* globals
    .EnableCsDateTime()); // optional: fluent DateTime API in JS

// Per request:
var engine = sp.GetRequiredService<IJsEngine>();
engine.SetValue("users", martenSession.Query<User>());

using (JsLinqContext.Scope(engine))
{
    var result = engine.Evaluate(
        "users.where(u => u.Name.startsWith('A') && u.IsActive)");
    var filtered = (IQueryable<User>)result.ToObject()!;
    var list = filtered.ToList();   // executes the translated SQL
}

IntelliSense for JS and C# LINQ aliases

Ship the embedded .d.ts next to your scripts so Monaco / VS Code knows both name sets:

// At startup, export the type-definitions file:
LinqTypeScriptDefinition.WriteTo("scripts/cocoar-jseval-linq.d.ts");
/// <reference path="./cocoar-jseval-linq.d.ts" />

// Now IntelliSense autocompletes BOTH:
u.Name.includes("x")   //  ✓
u.Name.Contains("x")   //  ✓
u.Tags.some(t => ...)  //  ✓
u.Tags.Any(t => ...)   //  ✓

Property Dependency Tracking

For reactive rule engines that must decide "did this change affect the query?":

var jsFn = engine.Evaluate(
    "(u) => u.Name.startsWith('A') && u.IsActive && u.Address.City === 'Vienna'");
var expr = JsExpressionTranslator.Translate<User, bool>(jsFn, engine);

var deps = ExpressionDependencyCollector.Collect(expr);
// deps.TopLevel: { "Name", "IsActive", "Address" }

userStore.OnUpdated += (user, changedProps) =>
{
    if (changedProps.Any(deps.DependsOn))
        RerunGroupMembership(user);   // else: skip
};

⚠️ Breaking Changes

Migration from v2.0.0

If you briefly had v2.0.0 installed, only one thing changed:

IJsEngine interface is gone. Use JsEngine directly.

// Before (v2.0.0)
var engine = sp.GetRequiredService<IJsEngine>();
public MyService(IJsEngine engine) { ... }

// After (v3.0.0)
var engine = sp.GetRequiredService<JsEngine>();
public MyService(JsEngine engine) { ... }

Why: IJsEngine claimed engine-swap abstraction that the library doesn't actually support (the whole codebase depends on Jint's JsValue/ScriptFunction/Acornima AST). Removing the ceremonial interface made room for the proper escape hatches (UnderlyingEngine, EvaluateExpression) that advanced consumers — like custom LINQ translator wiring — actually need.

No other v2.0.0 API changed. All features (AddLinq(), EnableCsDateTime(), linq.*, enum coercion, …) work identically.

Migration from v1.0.0 — Cocoar.JsEval.Expressions removed

The string-DSL expression builder is gone. It's superseded by Cocoar.JsEval.Linq, which produces the same Expression Trees from predicates written in natural JS/TS syntax — no more string paths.

Internal utilities (PropertyPath, ListHolder, EnumExpressionHelper, ExpressionRewriter) migrated into Cocoar.JsEval.Linq/Building/ as implementation details of the translator. If you relied on them directly, open an issue — we can promote specific helpers back to public on request.

Rewrite DSL-style calls as JS/TS predicates:

// Before (v1.0)
var expr = ExpressionHelper.Equal<User, string>("Name", "Alice");
var results = session.Query<User>().Where(expr).ToList();

// After (v3.0) — write the predicate in JS/TS and let the translator handle it:
engine.SetValue("users", session.Query<User>());
using (JsLinqContext.Scope(engine))
{
    var result = engine.EvaluateExpression("users.where(u => u.Name === 'Alice')");
    var filtered = (IQueryable<User>)result.ToObject()!;
    var list = filtered.ToList();
}
// Before: dotted path via string
var expr = ExpressionHelper.StartsWith<User>("Customer.Name", "A");

// After: direct property access
users.where(u => u.Customer.Name.startsWith('A'))
// Before: FilterBuilder
var filter = new FilterBuilder<User>()
    .Where("IsActive", true)
    .WhereIf(ids.Count > 0, b => b.Contains("Id", ids))
    .Build();

// After: plain JS chaining (same translated Expression)
const allowedIds = [linq.guid('...'), linq.guid('...')]; // or via closure
users.where(u => u.IsActive)
     .where(u => allowedIds.includes(u.Id))
// Before: EnumExpressionHelper — explicit storage-strategy choice in C#
var expr = EnumExpressionHelper.EqualsAsString<User, Status>("Status", Status.Active);

// After: direct comparison in JS — translator coerces string/int literals to
// the typed enum constant (case-insensitive for strings). The emitted Expression
// is the ORM-neutral native form, so int-stored and string-stored enums both
// translate correctly without any strategy flag.
users.where(u => u.Status === 'Active')
users.where(u => u.Status === 1)        // numeric ordinal also works

Direct replacements are available for every v1 feature. For edge cases (custom method mappings, unusual providers), use IJsMethodMap or a custom wrapper (see the LINQ guide).


Packages

Package Description
Cocoar.JsEval Core interfaces and helpers
Cocoar.JsEval.Engine JsEngine + fetch() + CsDateTime + DI
Cocoar.JsEval.TypeScript TypeScript 6.0 transpiler
Cocoar.JsEval.TsDefinition .d.ts generation for IntelliSense
Cocoar.JsEval.Linq (new) JS → LINQ: Expression translator, extensions, dependency collector, linq.* typed literals
Cocoar.JsEval.Module.Common Guid, Sleep, Random
Cocoar.JsEval.Module.Http Fluent HTTP client
Cocoar.JsEval.Module.Database SQL Server + PostgreSQL
Cocoar.JsEval.Module.Smtp Email via MailKit
Cocoar.JsEval.Module.Template Scriban templates
Cocoar.JsEval.Module.AngleSharp HTML parsing
Cocoar.JsEval.Module.Logging Microsoft.Extensions.Logging
Cocoar.JsEval.Module.VirtualFileSystem Zio VFS

Performance

Sub-microsecond translation across the board. Measurements taken with a reused Jint engine and a pre-parsed JS function (the typical hot-loop case). Full benchmark details: Performance Guide.

Predicate shape Mean Allocated
Simple boolean property (u => u.IsActive) 0.24 µs 632 B
String method (u.Name.startsWith('A')) 0.53 µs 1,232 B
Complex 3-clause && 0.78 µs 1,688 B
CsDateTime.AddDays + implicit op 0.78 µs 1,616 B
Nested lambda (u.Tags.some(t => …)) 0.95 µs 1,680 B
Cold (re-parse + translate) 2.58 µs 5,160 B

Reflection cache — hot-loop impact

Internal ReflectionCache memoizes GetProperty / GetMethod / MakeGenericMethod / implicit-op lookups (ConcurrentDictionary-backed, no eviction needed — reflection info is immutable). Impact is biggest on the shapes that reflected most:

Shape Before cache After cache Δ
Nested lambda 2.74 µs / 6.6 KB 0.95 µs / 1.7 KB −65% / −74%
CsDateTime + implicit op 1.31 µs / 3.0 KB 0.78 µs / 1.6 KB −40% / −46%

What this means for a real workload

For an ABAC-style rule engine that evaluates 10 000 objects through the same translated predicate per request:

Shape Total @ 10 000 iters
Simple 2.4 ms
Complex && 7.8 ms
Nested lambda 9.5 ms
CsDateTime inline 7.8 ms

A typical DB round-trip (5–50 ms) still dominates. The translator is ≤ 10 ms even for the heaviest shape — never the bottleneck.