v3.0.0
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/ThenByDescendingdirectly onIQueryable<T>- Dual naming — JS and C# LINQ both work. The translator accepts
u.Tags.some(...)andu.Tags.Any(...);str.includes(x)andstr.Contains(x);str.toLowerCase()andstr.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 withLinqTypeScriptDefinition.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 preservedlinq.today()/linq.now()/linq.utcNow()— zero-arg helpers that capture the current date/time at translation time. Compose withAddDays/AddHoursfor relative predicates:todos.where(t => t.DueDate < linq.today().AddDays(7))- Enum coercion —
u.Status === 'Active'andu.Status === 1both 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 implicitConvert(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 againstDateTimecolumns 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 packagesJsEngine.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.Linqservices.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 worksDirect 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.