Skip to content

Fix #4599: EnumerableContains.Parse crash on programmatic receivers with custom ToString()#4600

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/4599-enumerablecontains-receiver
Jun 2, 2026
Merged

Fix #4599: EnumerableContains.Parse crash on programmatic receivers with custom ToString()#4600
jeremydmiller merged 1 commit into
masterfrom
fix/4599-enumerablecontains-receiver

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Fixes #4599.

Problem

EnumerableContains.Parse (and the HashSetEnumerableContains sibling) crashed for Contains() receivers built as Property(Constant(<wrapper>), \"<member>\") when the wrapper type overrides ToString() — e.g. HotChocolate's ExpressionParameter<T> driving [UseFiltering] in. The stack:

InvalidOperationException: variable 'x' of type '<Doc>' referenced from scope '', but it is not defined
  at FastExpressionCompiler.ExpressionCompiler.CompileFast(...)
  at Marten.Linq.Parsing.LinqInternalExtensions.ReduceToConstant(Expression)
  at Marten.Linq.Members.ValueCollections.ValueCollectionMember.ParseWhereForContains(...)
  at Marten.Linq.Parsing.Methods.EnumerableContains.Parse(...)

Root cause: ConstantExpression.ToString() only wraps a value in \"value(<TypeName>)\" when the value uses the default Object.ToString(). When the wrapper overrides ToString(), the custom string is printed directly, and IsCompilableExpression's node.Expression.ToString().StartsWith(\"value(\") heuristic returns false. The receiver is then wrongly rejected by TryToParseConstant, and EnumerableContains.Parse falls into ParseWhereForContainsReduceToConstant(Arguments.Last()) — but Arguments.Last() is the value-side expression (doc.Name), which references the outer Where lambda parameter. ReduceToConstant wraps it in a parameter-less lambda and asks FEC to compile, which crashes with the unbound-parameter message.

The crash is on the value side; the trigger is the receiver being wrongly rejected.

Fix

  • IsCompilableExpression: replace the ToString().StartsWith(\"value(\") heuristic with a structural check — walk the MemberExpression's .Expression chain to its root, accept if it bottoms out at a ConstantExpression (or null, for static members). No string heuristic. This correctly accepts programmatic receivers regardless of ToString() behavior.
  • ReduceToConstant: add a free-parameter guard — a small ExpressionVisitor that finds any ParameterExpression not bound by a lambda within the expression. If found, throw a clear BadLinqExpressionException naming the free parameter instead of letting FEC surface the confusing scope-binding error. Defensive safety net for any future code path that mistakenly feeds a parameter-bearing subtree in.

After this change the receiver routes through EnumerableContains.Parse's constant-receiver branch and the query compiles to WHERE name = ANY(\$1) — same SQL as the manual Expression.Constant(List<T>) workaround.

Validation

  • New regression tests Bug_4599_enumerable_contains_programmatic_receiver cover both Enumerable.Contains and HashSet<T>.Contains receiver shapes against Postgres. Without the fix: both crash with the exact stack above. With the fix: both pass and return correct results.
  • The IsCompilableExpression / TryToParseConstant heuristic has 8+ call sites (Where, GroupBy, Any, Modulo, Dictionary, MemoryExtensions, Contains). Ran the full sweep — 437 / 437 LINQ tests pass, no regressions.

Backport

I'll open a companion PR cherry-picking this commit to the 8.0 branch — the reporter is on 8.37.1 and the bug exists there with the same code.

…heck

EnumerableContains.Parse (and the HashSet sibling) crashed for receivers built
as Property(Constant(<wrapper>), "<member>") when the wrapper type overrides
ToString() -- e.g. HotChocolate's ExpressionParameter<T> driving [UseFiltering]
`in`. ConstantExpression.ToString() only wraps in "value(<TypeName>)" when the
value uses the default Object.ToString(); with a custom override, it prints the
custom string directly. The IsCompilableExpression heuristic
(node.Expression.ToString().StartsWith("value(")) then returned false, the
receiver was wrongly rejected by TryToParseConstant, and the parser fell into
ParseWhereForContains -> ReduceToConstant on the *value-side* expression. That
expression still referenced the outer Where lambda parameter, so FEC compiled
a parameter-less lambda with a free parameter inside and crashed with
"variable 'x' of type '<Doc>' referenced from scope '', but it is not defined".

- IsCompilableExpression: walk the member chain to its root and accept if the
  root is a ConstantExpression (or null, for static members). Structural; no
  string heuristic. Accepts the previously-rejected programmatic-receiver shape.
- ReduceToConstant: add a free-parameter guard (visitor that ignores parameters
  bound by lambdas *within* the expression). If a free parameter is found,
  throw a clear BadLinqExpressionException naming it rather than letting FEC
  surface the confusing scope-binding message. Safety net for any other call
  site that might mistakenly feed a parameter-bearing subtree.

Tests cover both Enumerable.Contains and HashSet<T>.Contains receiver shapes
against Postgres; the previously-failing repros now pass. Full
LinqTests Contains/Where/GroupBy/Any/Modulo sweep (437 tests) still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

EnumerableContains.Parse crashes on programmatically-built array.Contains(field) — fragile IsCompilableExpression heuristic

1 participant