Skip to content

v3.1.1

Choose a tag to compare

@windischb windischb released this 18 Apr 10:54
· 45 commits to develop since this release

Cocoar.JsEval v3.1.1

Optional chaining actually works against Marten / EF / LINQ2DB now.

v3.1.0 shipped optional chaining (?.) but couldn't survive contact with real LINQ providers — every ?. in a Where clause produced nested IIF nodes that Marten, EF Core, and LINQ2DB refuse. Downstream projects rolled back to hand-written != null && chains. This release rewrites the chain lowering so the common shapes emit the form a C# author would hand-write: pure &&-conjunctions, zero IIF nodes, translated natively by every mainstream provider.

The failures v3.1.0 users are hitting

Two real-world scripts from an authorization project's demo-seed.json, both rolled back to pre-?. syntax:

// 1. Person-name-prefix authorization, two-hop optional chain
(p) => p.Type === 'Person' && p.IsActive && (
  p.Person?.Firstname?.startsWith('A') ||
  p.Person?.Firstname?.startsWith('L') ||
  p.Person?.Firstname?.startsWith('P'))

// 2. Customer-scoped access filter, single optional hop in binary comparison
(t) => t.Customer?.Id === linq.guid('...acme...') ||
       t.Customer?.Id === linq.guid('...alpine...') ||
       t.Customer?.Id === linq.guid('...central...')
Marten.Exceptions.BadLinqExpressionException: Whoa pardner, Marten could not parse
'IIF((p.Person == null), null, IIF((p.Person.Firstname == null), null,
 Convert(p.Person.Firstname.StartsWith("A"), Nullable`1)))'
Marten.Exceptions.BadLinqExpressionException: Unsupported nested operator 'NotEqual'
 as an operand in a binary expression

Both scripts run green against Marten in this release. Verified end-to-end in the JsEval.Marten.Sandbox project against PostgreSQL.

Four translator rewrites

The fix needed four interlocking pieces — dropping any one leaves a known-broken shape on the table:

1. VisitChain emits one flat conditional, not N nested

// v3.1.0:  IIF(g1 == null, null, IIF(g2 == null, null, body))
// v3.1.1:  IIF(g1 != null && g2 != null, body, null)

All guards combine into a single positive test. Non-nullable value-type guards (Guid, int) are skipped — they can never be null.

2. NormalizeToBool collapses bool-context chains to &&-chains

At every boolean-context site (lambda body with TResult=bool, operand of !, &&, ||, ternary test), a chain whose body is bool rewrites from IIF(test, body, null) to test && body — no IIF at all.

(u) => u.Address?.City.startsWith('V')
// → u.Address != null && u.Address.City.StartsWith("V")

3. VisitBinary flattens chains against known-non-null constants

For == / === / < / <= / > / >=, when one operand is a chain IIF and the other is a known-non-null constant (literal, linq.* typed literal, or closure pointing at a non-null value), the translator flattens:

t.Customer?.Id === linq.guid('...')
// → t.Customer != null && t.Customer.Id == guid

!= is deliberately excluded — null != X has distinct CLR semantics (returns true) that don't match the &&-flatten shape.

4. Redundant bool-constant comparisons collapse

x === true → x, x === false → !x, x !== true → !x, x !== false → x — whenever the non-constant side is plain bool (not bool?, where lifted semantics matter). Marten specifically can translate StartsWith("x") but not StartsWith("x") == true; this unblocks v3.1.0-syntax scripts that used === true as the nullable-bool workaround.

Verified against all three mainstream providers

Each of the JsEval.*.Sandbox projects now includes an OptionalChainingScenario that exercises the two rollback scripts in three variants each (natural v3.1.1 syntax, v3.1.0 === true style, hand-rolled workaround) against real databases:

Provider DB Natural === true Hand-rolled
Marten Postgres ✓ 3 rows ✓ 3 rows ✓ 3 rows
EF Core SQLite ✓ 3 rows ✓ 3 rows ✓ 3 rows
LINQ2DB SQLite ✓ 3 rows ✓ 3 rows ✓ 3 rows

All three script variants produce functionally identical results on every provider — the natural v3.1.1 form is just auto-generated from cleaner JS source instead of hand-rolling it.

Truth table preserved

Script match no match inner null outer null
p.Person?.Firstname?.startsWith('A') ✓ ✗ ✗ ✗
!p.Person?.Firstname?.startsWith('A') ✗ ✓ ✓ ✓ (JS: !undefined === true)
t.Customer?.Id === guid ✓ ✗ — ✗
u.IsActive === true ≡ u.IsActive
u.IsActive === false ≡ !u.IsActive

Compatibility

  • Drop-in from 3.1.0. Scripts that used === true still work; the new simplification makes them equivalent to the natural form. Hand-rolled != null && chains still work unchanged.
  • No API surface changed. JsExpressionTranslator.Translate, JsLinqExtensions, JsEngine — all unchanged.
  • Non-chain predicates unaffected. u.IsActive, u.Name.startsWith('A'), u.Tags.some(...) etc. produce byte-identical Expression trees to v3.1.0.
  • 19 new unit tests cover the truth table for single/double-guard chains × (direct / ! / && / || / ternary / ===-constant / === true / === false) × (null / match / non-match). Shape assertions prove no IIF leaks into bool-context and no IIF-inside-binary-comparison.

Sandbox

New OptionalChainingScenario in all three sandbox projects (JsEval.Marten.Sandbox, JsEval.EfCore.Sandbox, JsEval.Linq2Db.Sandbox) reproduces both rollback scripts against real databases. Marten needs Postgres on localhost:5432 (postgres/postgres/postgres); EF Core and LINQ2DB use SQLite in-memory so they're runnable with zero setup.

Packages

Package Changed in 3.1.1
Cocoar.JsEval.Linq Chain flatten + binary-comparison flatten + bool-const collapse + boolean-context &&-rewrite
everything else version bump only, for consistency

Acknowledgement

Surfaced by the same authorization-project integration that drove v3.1.0 — the first real workload to exercise optional chaining against Marten, and by extension the first to hit every single shape that v3.1.0 got wrong. This is the release that makes ?. actually work where people need it.