Skip to content

Translate the LINQ operators that materialise a sequence - #99

Merged
joadan merged 1 commit into
mainfrom
linq-operator-translation
Sep 8, 2026
Merged

joadan merged 1 commit into
mainfrom
linq-operator-translation

Conversation

@joadan

@joadan joadan commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

ToDictionary, ToLookup and GroupBy were classified Unsupported, so a client writing

e.Nodes.ToDictionary(n => n.ResourceReference, n => n, StringComparer.OrdinalIgnoreCase)

got "the LINQ operator 'ToDictionary' cannot be translated to a GraphQL selection" instead of a query. Nothing about them is untranslatable: the query tree only records which fields are mentioned and where they hang, and their lambdas read fields of a single sequence — the pass-through shape. The keying itself happens on the client after the response is deserialized.

What changed

  • LinqOperator — added the single-sequence operators to PassThroughOperators: ToDictionary, ToLookup, GroupBy, plus the ones the list had never caught up with (Append, Contains, CountBy, Index, Prepend, Shuffle, TryGetNonEnumeratedCount). LinqOperatorKind now says what Unsupported actually means: operators that combine several sequences (Concat, Join, Zip) or fold into an accumulator (Aggregate).
  • QueryExpressionVisitor — pass-through lambdas bind their later parameters too when the parameter is a sequence of the element type, so GroupBy's result selector (key, orders) => … can select through orders instead of reporting it as unbound.
  • Error message — the unsupported-operator message now names the way out, since the operator can almost always be applied to the result of ExecuteAsync.
  • GraphQueryTranslationException — translation failures were the one failure category with no named type and no structured context; they now throw this, carrying the offending Expression and the operator/member name. It derives from NotSupportedException, which is what the parser threw before, so existing catch clauses keep working.

Why the list stays an allow-list

Classifying by shape instead would remove the maintenance, but it changes how the code fails when it meets an operator we haven't anticipated. An unlisted operator throws today: loud, and one line to fix. Guessing pass-through would instead emit a query missing fields, which surfaces as nulls and defaults in a deserialized object with no error anywhere — the failure mode the parser rewrite set out to eliminate. It also wouldn't remove the name knowledge entirely, since Select/SelectMany must still be recognized to move the selection and the combining operators still need explicit rejection.

Tests

Four parser cases in ExpressionParserTests (ToDictionary with an element selector, with a comparer, GroupBy, GroupBy with a result selector) and two end-to-end cases in QueryOperatorTests. Both UnsupportedOperator_IsReported tests used GroupBy as their example and now use Concat; they assert the hint and MemberName, and the parser one pins that the exception is still assignable to NotSupportedException. Full CI suite: 166 passed. No generator or template changes, so the generated-clients drift check is unaffected.

ToDictionary, ToLookup and GroupBy were classified Unsupported, so a client
writing e.Nodes.ToDictionary(n => n.ResourceReference, n => n, comparer) got
"the LINQ operator 'ToDictionary' cannot be translated to a GraphQL selection"
instead of a query. Nothing about them is untranslatable: the query tree only
records which fields are mentioned and where they hang, and their lambdas read
fields of a single sequence, which is the pass-through shape - the keying itself
happens on the client after the response is deserialized. Add them, along with
the operators the list had never caught up with (Append, Contains, CountBy,
Index, Prepend, Shuffle, TryGetNonEnumeratedCount), and say in LinqOperatorKind
what is left: operators that combine several sequences or fold into an
accumulator.

The list stays an allow-list rather than becoming shape based. An operator we
have not listed throws, which is loud and one line to fix; guessing pass-through
for an unknown operator would instead produce a query missing fields, and that
surfaces as nulls in a deserialized object with no error anywhere.

Pass-through lambdas now bind their later parameters too when the parameter is a
sequence of the element type, so GroupBy's result selector - (key, orders) =>
... - can select through orders instead of reporting it as unbound.

The Unsupported message gained a hint naming the way out, since the operator can
almost always be applied to the result of ExecuteAsync, and translation failures
now throw GraphQueryTranslationException carrying the offending Expression. It
derives from NotSupportedException, which is what the parser threw before, so
existing catch clauses keep working.
@joadan
joadan merged commit 57191f1 into main Sep 8, 2026
2 checks passed
@joadan
joadan deleted the linq-operator-translation branch September 8, 2026 13:24
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