Skip to content

Rewrite the expression parser - #92

Merged
joadan merged 1 commit into
mainfrom
rewrite-expression-parser-v2
Sep 7, 2026
Merged

joadan merged 1 commit into
mainfrom
rewrite-expression-parser-v2

Conversation

@joadan

@joadan joadan commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

The parser built a MemberNode tree only to convert it into the QueryNode tree, bound lambda parameters by walking up parents and falling back to the root when no match was found, and looked at only Arguments[0] of a LINQ operator. That left three failures:

  • e.Nodes.First(), .Count() and .ToList() threw ArgumentOutOfRangeException, because Arguments[1] was read whenever Arguments[0] was an attributed member, whether a lambda was passed or not.
  • A chained operator produced an invalid query. In e.Nodes.Where(n => n.Customer.CustomerName != null).Select(n => n.OrderId) the outer Select fell through to the base visitor, so its parameter was unknown and orderId was attached to the root: "The field orderId does not exist on the type OrdersConnection".
  • SelectMany's result selector bound both of its parameters to the outer element: "The field orderId does not exist on the type Customer".

QueryExpressionVisitor now builds QueryNode directly, so merging happens once in AddChildNode. ResolvePath handles the expressions that name a field and returns the node they select; anything else falls through to the inherited ExpressionVisitor, so fields mentioned inside initialisers, comparisons and ordinary method calls are still fetched. Lambda parameters are bound in an explicit reference keyed scope map, and a parameter that was never bound is reported instead of silently selecting from the root.

LinqOperator classifies an operator as Projection, where the selection moves to the lambda result, or PassThrough, where it stays on the source and the lambda is a client side predicate or key selector whose members still have to be fetched. Where, OrderBy, Take, First, Count, Cast and the rest are supported and can be chained; the operators that combine several sequences throw a NotSupportedException naming the operator rather than quietly dropping fields. Convert, TypeAs, Quote and Unbox are unwrapped, so a cast or a Nullable lift no longer truncates a path.

ExpressionEvaluator reads constants, captured locals and transparent conversions directly and compiles only as a fallback. Arguments were previously evaluated twice per call.

GetArgumentsId has to stay a pure function of the argument values, because GetMethodValue recomputes it to find the field in the response. It now renders values structurally and hashes with FNV-1a/64 instead of object.GetHashCode(), which was reference based: aliases were not the same in two processes, so the query text could not be cached, and a collision silently merged two distinct argument sets into one field.

The parser built a MemberNode tree only to convert it into the QueryNode
tree, bound lambda parameters by walking up parents and falling back to the
root when no match was found, and looked at only Arguments[0] of a LINQ
operator. That left three failures:

- e.Nodes.First(), .Count() and .ToList() threw ArgumentOutOfRangeException,
  because Arguments[1] was read whenever Arguments[0] was an attributed
  member, whether a lambda was passed or not.
- A chained operator produced an invalid query. In
  e.Nodes.Where(n => n.Customer.CustomerName != null).Select(n => n.OrderId)
  the outer Select fell through to the base visitor, so its parameter was
  unknown and orderId was attached to the root: "The field `orderId` does not
  exist on the type `OrdersConnection`".
- SelectMany's result selector bound both of its parameters to the outer
  element: "The field `orderId` does not exist on the type `Customer`".

QueryExpressionVisitor now builds QueryNode directly, so merging happens once
in AddChildNode. ResolvePath handles the expressions that name a field and
returns the node they select; anything else falls through to the inherited
ExpressionVisitor, so fields mentioned inside initialisers, comparisons and
ordinary method calls are still fetched. Lambda parameters are bound in an
explicit reference keyed scope map, and a parameter that was never bound is
reported instead of silently selecting from the root.

LinqOperator classifies an operator as Projection, where the selection moves
to the lambda result, or PassThrough, where it stays on the source and the
lambda is a client side predicate or key selector whose members still have to
be fetched. Where, OrderBy, Take, First, Count, Cast and the rest are
supported and can be chained; the operators that combine several sequences
throw a NotSupportedException naming the operator rather than quietly
dropping fields. Convert, TypeAs, Quote and Unbox are unwrapped, so a cast or
a Nullable lift no longer truncates a path.

ExpressionEvaluator reads constants, captured locals and transparent
conversions directly and compiles only as a fallback. Arguments were
previously evaluated twice per call.

GetArgumentsId has to stay a pure function of the argument values, because
GetMethodValue recomputes it to find the field in the response. It now
renders values structurally and hashes with FNV-1a/64 instead of
object.GetHashCode(), which was reference based: aliases were not the same in
two processes, so the query text could not be cached, and a collision
silently merged two distinct argument sets into one field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@joadan
joadan merged commit 2098c97 into main Sep 7, 2026
1 check passed
@joadan
joadan deleted the rewrite-expression-parser-v2 branch September 7, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant