v3.1.0
Cocoar.JsEval v3.1.0
Optional chaining, nullish coalescing, and a DI lifetime fix.
A focused follow-up to v3.0.0 based on authoring friction surfaced by a real-world ABAC-style authorization project built on Cocoar.JsEval.Linq. The script-syntax reference in that project's docs claimed ?. and ?? were supported — they weren't. Rather than correct the docs, we fixed the library. Both features slot into the translator without touching the hot path, and they make null-safe predicates authorable rather than boilerplate.
Highlights
- Optional chaining (
?.) in predicates. Each?.step short-circuits the rest of the chain tonullif the guarded target is null. Works in both SQL translation and in-memoryExpression.Compile(). - Nullish coalescing (
??) in predicates. Maps toExpression.Coalesce. Composes naturally with?.. JsEngineDI lifetime is now scoped (was transient). One engine per DI scope, shared across collaborators — matches the "Jint is not thread-safe" contract.
Null-safe predicates, without the ceremony
Before v3.1, writing a null-safe predicate meant explicit != null && … chains:
// v3.0 — verbose but explicit
(u) => u.Address != null && u.Address.City != null && u.Address.City.startsWith('V')With v3.1:
// Optional chaining short-circuits the rest of the chain on null
(u) => u.Address?.City.startsWith('V') === true
// Nullish coalescing for fallback values
(u) => (u.Address?.City ?? '') === 'Vienna'
// Both combine with every existing translator feature — linq.*, enums, method maps, closures, etc.
todos.where(t => (t.Customer?.Tier ?? 'none') === user.Tier)What the translator emits
u.Address?.City→u.Address == null ? null : u.Address.City(result type nullable)- Nested chains like
a?.b?.cwrap outermost-first:a == null ? null : (a.b == null ? null : a.b.c)— so the outer guard short-circuits past the inner - Non-nullable value-type guards are skipped (the target can never be null — e.g.
u.Id?.ToString()whereIdis aGuid) — no wastedConditionnode ??usesExpression.Coalescedirectly; left side must be a reference type orNullable<T>(CLR contract)
For in-memory evaluation (auto-membership recalc, unit-test shims, small IEnumerable filtering), this replaces a try/catch-around-NRE safety net with actual null-safe navigation.
DI lifetime: Scoped, not Transient
Before v3.1, AddJsEval registered JsEngine as transient. Every GetRequiredService<JsEngine>() produced a fresh engine. Two services depending on JsEngine in the same HTTP request would get different engines — and any globals set via SetValue on one would be invisible to the other.
// v3.1: one engine per scope, shared across collaborators
public class AccessPolicyEngine(JsEngine engine) { /* sets globals, runs scripts */ }
public class AutoMembershipRecalc(JsEngine engine) { /* same engine, sees same globals */ }Jint engines are not thread-safe — so scoped is the correct lifetime: shared within a request pipeline, isolated across concurrent requests.
Migration from v3.0
If you relied on each resolve producing a fresh engine, either:
- Wrap the work in its own DI scope:
using var scope = sp.CreateScope(); var engine = scope.ServiceProvider.GetRequiredService<JsEngine>(); - Construct explicitly:
new JsEngine(sp, moduleRegistry, options, logger)
Most consumers want the new default — this flips the footgun into the safer shape.