Repository navigation
v3.1.1
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
=== truestill 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 noIIFleaks 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.