ADR 0047: Fluent Cypher C# predicate lambdas under an agree-or-reject rule¶
Date: 2026-10-16. Status: Accepted 2026-10-16 for the first slice (comparisons, null tests, &&/|| and bare Booleans); amended the same day for IN, string operators, negation, enum ordering and embedded predicates (see Amendment 1); see Verification. Amends the predicate-syntax decision in ADR 0041; every other ADR 0041 decision is unchanged.
Context¶
ADR 0041 chose explicit predicate methods (actor.Property(p => p.Name).Eq(name)) over a Boolean C# lambda. The stated reason: a lambda looks like C#, but its equality, nullable values and short-circuit expectations conflict with Cypher's three-valued logic. ADR 0041 left shorter predicate syntax open "only if pilot evidence justifies" it, and "never change native semantics merely to shorten an example".
The compiled gallery shows the cost of the explicit form. Predicate ceremony dominates the six sample queries, and Return already accepts an expression tree over the same alias.Value.Member shape, so the surface is inconsistent. The objection in ADR 0041 is about semantics, not syntax. It is resolved if a lambda can only express predicates whose native result equals their C# meaning.
In a WHERE clause, a row is kept only when the predicate is true. Under AND/OR without negation, Cypher's null and C#'s false both drop the row, so they are indistinguishable. Disagreement arises only where C# turns a null comparison into true: != against a null operand, == between two nulls, and negation.
Decision¶
Add these overloads beside the existing explicit API:
CypherQuery.Where(Expression<Func<bool>> predicate)CypherQuery.OptionalMatch(CypherPattern pattern, Expression<Func<bool>> where)andOptionalMatch(CypherPattern pattern, CypherPredicate where)- Lambda ordering:
OrderBy/OrderByDescending/ThenBy/ThenByDescending(Expression<Func<T>> key)
The lambda is syntax for a native predicate, never executed. Translation follows one rule:
Agree or reject. Emit only native Cypher whose
WHEREoutcome equals the C# outcome for every row. Never add compensating clauses (OR x IS NULL,coalesce). If no single native form agrees, reject before transport with a coded diagnostic that names the explicit combinator to use instead.
| C# shape | Emitted Cypher | Accepted when | Why |
|---|---|---|---|
a == v, a < v, a <= v, a > v, a >= v with a non-null value |
a op $p |
Always | Null drops the row in both languages under AND/OR |
a == null, a != null, or a captured value that is null |
a IS NULL, a IS NOT NULL |
Always | Both forms are two-valued |
a != v with a non-null value |
a <> $p |
a is never null |
Otherwise C# keeps null rows and Cypher drops them |
a == b (two symbol terms) |
a = b |
At least one side is never null | C# null == null is true; Cypher returns null |
a != b (two symbol terms) |
a <> b |
Both sides are never null | Same reason as a != v |
Bare bool member a |
a = true |
Always | Both are true only for true |
&&, \|\| |
AND, OR |
Operands are accepted shapes | Monotone composition preserves agreement |
!expr |
— | Rejected in the first slice; accepted over never-null operands by Amendment 1 | Use .Not() |
| Ordering comparison on a property with a value converter | — | Rejected (enums accepted by Amendment 1) | Converters do not prove order preservation; matches ordinary LINQ |
Comparison of native DateTimeOffset |
— | Rejected | Fluent keeps native zoned semantics; C# compares instants |
Method calls, arithmetic, new, getters on captured objects, entity/path comparisons |
— | Rejected | No client evaluation and no user code during translation |
Never null means the EF property is not nullable and the symbol is non-optional at that point:
- bound by
MATCH, or - bound by
OPTIONAL MATCHand referenced inside that same clause'sWHERE(pattern-bound there), or - an aggregate introduced by
WITH(counts are never null).
A symbol first bound by OPTIONAL MATCH may be null in every later clause. EF requiredness of stored data is trusted, as provider materialization already trusts it.
Equality on a converted property is accepted, like ordinary LINQ and the explicit Eq combinator. It relies on the documented assumption that conversion preserves equality (null and type semantics).
Capture timing. Captured values are read when Where is called and stored as parameters, like the existing Eq(value). A built stage is therefore an immutable value; it does not change when a captured variable changes later. This deliberately differs from EF LINQ's deferred parameter evaluation. Captures are limited to constants, closure fields and static fields, with safe nullable/numeric widening conversions. Property getters and method calls are rejected so that translation never runs user code.
Diagnostics. New lambda failures throw NotSupportedException, keeping the existing catch contract. Each carries a stable code (GBFC101–GBFC199) in its message and in Exception.Data["GraphBridge.FluentDiagnostic"]. A separate change may introduce a dedicated exception type for all Fluent diagnostics.
Explicit combinators remain. Eq/Ne/Gt/..., And/Or/Not, IsNull/IsNotNull and Graph.Exists keep native three-valued semantics. They are the escape hatch for every shape the lambda rejects. .Where(lambda) and .Where(predicate) can be chained; both combine with native AND. For every accepted shape, the lambda form and the equivalent combinator form must compile to byte-identical Cypher.
var actor = Graph.Node<Person>(); var movie = Graph.Node<Movie>(); var coactor = Graph.Node<Person>();
var query = db.Cypher()
.Match(actor.Out<Credit>(movie).In<Credit>(coactor))
.Where(() => actor.Value.Name == actorName && coactor.Value.Id != actor.Value.Id)
.ReturnDistinct(() => coactor.Value.Name)
.OrderBy(() => coactor.Value.Name)
.Limit(100);
coactor.Value.Id != actor.Value.Id is accepted because both keys are non-nullable and MATCH-bound. person.Value.Nickname != "Bob" is rejected (GBFC110): C# keeps people without a nickname, Cypher drops them. Choose the meaning explicitly with combinators:
.Where(person.Property(p => p.Nickname).Ne("Bob"))for native semantics (people without a nickname are dropped), or.Where(person.Property(p => p.Nickname).IsNull().Or(person.Property(p => p.Nickname).Ne("Bob")))for the C# meaning.
The checks are per comparison, so they are conservative. Some compositions that would agree, such as x == null || x != v, are still rejected in this slice.
Amendment 1: language depth and embedded predicates¶
Date: 2026-10-16. Status: Accepted with the verification below. The agree-or-reject rule and the no-compensation rule are unchanged; this amendment only accepts more shapes that satisfy them.
| C# shape | Emitted Cypher | Accepted when | Why |
|---|---|---|---|
values.Contains(a) over a captured array, List<T> or HashSet<T> with default (or ordinal string) equality |
a IN $p |
At most 1,024 values (the LINQ Contains limit); no NaN; non-temporal member |
Default equality of the member's own type equals native equality of its stored form, as for == |
| …with a null element | a IN $p without the null |
a is never null |
A never-null member cannot equal null in either language. If a can be null, C# Contains(null) is true and native IN is null, so the shape is rejected (GBFC110); the user writes \|\| a == null |
a.StartsWith(v, StringComparison.Ordinal), EndsWith(…, Ordinal), Contains(v) and the char overloads |
STARTS WITH, ENDS WITH, CONTAINS |
a is an unconverted string member; v is a captured non-null value |
Ordinal matching equals native exact matching. A null member makes both false-or-null, which drop the row under AND/OR, as in EF LINQ |
| Culture-sensitive or case-insensitive string overloads | — | Rejected (GBFC106) | The current culture and case folding have no exact native equivalent |
!p |
NOT p |
p is two-valued: null tests, EXISTS, and comparisons, IN or string operators whose operands are never null, and AND/OR/NOT of these |
Over two-valued operands, C# and native negation agree; otherwise C# turns false into true and NOT null stays null (GBFC104) |
| Ordering over an enum member | a op $p |
Always | GraphBridge's own enum converter stores the numeric value, which is monotonic. Other converters stay rejected (GBFC111) |
Graph.Is(predicate) |
The predicate | Always; under !, only if two-valued. In Return it is a Boolean column, read as bool only if two-valued, otherwise as bool? (GBFC407) |
Embeds an explicit native predicate (typically Graph.Exists) in a lambda. It keeps native semantics; AND/OR cannot make them disagree with C#. Inline arguments are built by running only Fluent Cypher builder code over captured values; anything else is rejected without being run |
Explicit native counterparts are added for every new lambda shape: CypherExpression<T>.In(values) and .StartsWith/.EndsWith/.Contains(value or expression) on string expressions. They keep native semantics (a null In element stays in the list) and refuse converted string properties, because converters do not preserve prefixes or substrings.
An independent review of the amendment found three accepted shapes that could disagree with C#, fixed before acceptance: inexact double-to-integer narrowing passed a decimal-rounded exactness check (now an exact round trip in the value's own type); an out-of-range number compared with an enum was truncated (now a checked conversion); and a converted property compared with a WITH value was compared without its converter (now GBFC111, except the numeric enum converter). The review also led to rejecting == on reference types other than string, never calling a user comparer's Equals, and keeping every Graph.Is and static-initializer failure coded. Lambda OrderBy is outside this rule: it is native ordering (nulls last ascending, converted keys by stored form), documented as such.
Verification: provider unit tests (FluentCypherLambdaDepthTests) cover every accepted shape with its explicit twin, every rejection code, copy-at-Where, model-value conversion for enums, Guids and converted strings, the collection and comparer rules, and that Graph.Is never runs application code. The live oracle in ProviderFluentLambdaTests adds IN, null elements, Guid sets, string operators, negation and an unguarded null-member string test against C# evaluation over the seed rows, plus embedded EXISTS under || and !. Enum ordering is verified by unit tests only; the live seed model has no enum.
Amendment 2: a typed Fluent exception¶
Date: 2026-10-17. Status: Accepted. The "separate change" anticipated under Diagnostics: every coded Fluent refusal now throws FluentCypherException, a public sealed type derived from NotSupportedException, with a Code property. The message and the Exception.Data["GraphBridge.FluentDiagnostic"] key are unchanged, so catch (NotSupportedException) handlers keep working. Code that compares the exact runtime type, such as xUnit's Assert.Throws<NotSupportedException>, must switch to FluentCypherException or an assignable check. For symmetry, the LINQ Neo4jQueryTranslationException gains the same Code property. Clause, symbol and member fields are not added; they would touch every failure site and wait for a concrete need. Builder misuse keeps InvalidOperationException and ArgumentException without codes.
Verification: 799 provider units, including the typed-code contract (the property, Data and the message agree; ThrowsAny<NotSupportedException> still catches it), with the Fluent refusal assertions tightened to the new type. 266 tooling, 47 generator and 5 offline workshop tests. Full live integration suite 284/284 and live workshop 8/8 on the owned disposable fixture.
Not adopted¶
The v2 proposal's "Level 2" From<T>().Out<TEdge,TNode>().Where((a, r, b) => ...) builder is not adopted. It would add a third expression dialect (lambda parameters without .Value), and it ends at one hop. The EF-familiar on-ramp is navigation translation in ordinary LINQ (a possible future direction), which also yields tracked entities. The small conveniences adopted instead are Out<TEdge>(target) / In<TEdge>(source) overloads that create an anonymous edge symbol, and lambda ordering.
Verification¶
- Unit Cypher baselines for every accepted shape, and parity: lambda and combinator forms produce identical command text and parameters.
- Rejection tests for every rejected shape, before transport, with the expected code; a captured method is proven not invoked.
- Capture snapshot test: changing a captured variable after
Wheredoes not change the command. - Optional-scope tests: a symbol is never-null inside its own OPTIONAL MATCH
WHERE, and may-be-null after it. - Live agreement oracle on the pinned Neo4j fixture: for each accepted predicate, the Fluent result equals the same C# predicate evaluated over the in-memory seed rows, including null, missing-property and optional-match rows.
- The compiled sample gallery rewritten with lambdas still matches its reviewed
.cyphersnapshots byte for byte.
Result, 16 October 2026 (pinned SDK 10.0.401, Neo4j 2026.09.0 Community, owned disposable fixture): every item above passes. 775 provider units (nine new lambda cases), 265 tooling units, 28 focused live cases (four new lambda/oracle cases), 22 tooling live cases and the offline package consumer with a lambda parity check. Exact commands and what was not rerun are in the acceptance record.
Consequences¶
The common predicates read like C#, and every accepted predicate means what it says in C#. Some natural C# predicates (!= on nullable properties, !) are rejected at first; each diagnostic names the explicit alternative. The compiler gains per-clause optional-symbol tracking. The public surface grows by one non-generic CypherNode base class and the overloads listed above. No new runtime dependency, tracking behavior or execution path is introduced.