Skip to content

Treat an inline array literal as a value in LINQ Contains(), including a compiled query's own members (supersedes #5691) - #5712

Merged
jeremydmiller merged 3 commits into
masterfrom
claude/eager-volta-93f7i6
Oct 19, 2026
Merged

jeremydmiller merged 3 commits into
masterfrom
claude/eager-volta-93f7i6

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Supersedes #5691 by @lahma, whose commit is carried here unchanged (with Marko as its author) and as co-author on the follow-up commit.

What fails on master

session.Query<Swatch>().Where(x => new[] { "Red", "Blue" }.Contains(x.Name))

This throws BadLinqExpressionException : Unable to find any queryable members in expression new [] {"Red", "Blue"}. The same happens for int, Guid and enum literals, the static Enumerable.Contains(new[] { … }, x.Name) form, the negated form, and a duplicated field. An array of captured values, new[] { id1, id2 }, fails with Marten does not (yet) support member Guid.id1. See #5691 for the full diagnosis: TryToParseConstant did not recognise a NewArrayInit.

What this PR does

1. lahma's fix (#5691). TryToParseConstant accepts a NewArrayInit whose elements read no lambda parameter and reduces it with ReduceToConstant.

2. The compiled-query shape now works instead of being refused. #5691 deliberately left new[] { First, Second }.Contains(x.Name) failing inside a compiled query. The plan binds each parameter to one query member by matching values, and an array assembled from two members matches neither, so reducing it would freeze the planning-time values.

CompiledQueryPlan.MatchParameters now handles an array parameter that no member matches as a whole by matching it element by element:

  • Each element equal to a scalar member's planning value is re-read from that member on every execution. Enum members are matched as raw values or as stored (number or name).
  • An element that no member accounts for is a literal and keeps its value.

The setter clones the planning array, so it creates no array type at runtime. This also fixes x.Name.IsOneOf(new[] { First, Second }), which on master silently keeps its planning-time values. #5691's CompiledQueryReadFinder guard is removed.

3. CoreTests expectations. #5691's CI failed in document_store_diagnostics_criteria_shape_matrix. That matrix expected Dynamic LINQ's inline Score in (1, 2, 3) and CustomerName in ("Ann", "Bob") to be refused. Both reach LINQ as array literals and now translate correctly (7 of 7 and 3 of 3 rows against the oracle), so the expectations are now Correct. The DiagnosticsCriteriaPolicies remark that said inline in (…) still throws is updated to match.

Tests

LinqTests/Bugs/Bug_inline_array_literal_as_contains_source.cs has 16 tests. lahma's 11 non-compiled and constant-compiled cases are unchanged. The test that pinned the refusal is replaced by five tests, six cases in all, each running a compiled query twice with different values. The second run is served from the cached plan, so a frozen plan answers it with the first run's rows.

Test Shape
compiled_query_with_an_array_literal_of_its_own_members new[] { First, Second }.Contains(x.Name)
compiled_query_with_is_one_of_over_an_array_literal_of_its_own_members x.Name.IsOneOf(new[] { First, Second }), which is also broken on master
compiled_query_with_a_member_and_a_literal_in_one_array new[] { Name, "Green" }; the literal keeps its value
compiled_query_with_an_array_literal_of_int_members int members
compiled_query_with_an_array_literal_of_enum_members(AsInteger / AsString) enum members, both storages

With the element binding disabled, all six cases fail; with it, 16 of 16 pass.

Local runs on net10.0, before merging current master in:

  • CompiledQueryTests: 9 of 9 passed.
  • LinqTests: 1647 of 1647 passed.
  • CoreTests: 818 of 818 passed.

net9.0 is covered by CI; on net9.0 the instance Contains form binds to Enumerable.Contains rather than MemoryExtensions.Contains.

Known gap, tracked in #5711

Binding is still by value, so one shape stays wrong: a literal equal to a member's value on the first call gets bound to that member. new[] { Name, "BB" } planned with Name = "BB" then answers Name = "aa" with [aa]. Scalar parameters already have this hazard on master: x.Name == Name || x.Name == "BB" does the same. #5711 records it alongside derived values that freeze at plan time (First + Second, Name.ToUpper()) and new List<T> { … }, which is still refused. It proposes binding by reading the query object instead of by matching values.


Generated by Claude Code

lahma and others added 3 commits October 19, 2026 17:23
`new[] { "a", "b" }.Contains(x.Name)` failed to parse with "Unable to find
any queryable members in expression new [] {"a", "b"}", and an inline array
of captured values, `new[] { id1, id2 }.Contains(x.Id)`, with "Marten does
not (yet) support member Guid.id1". The same values in a captured local
array, or passed to IsOneOf(), have always worked.

The Contains parsers (EnumerableContains, and MemoryExtensionsContains for
the C# 14 span binding) decide which side is the value through
TryToParseConstant, which accepted only a ConstantExpression or a closed
MemberExpression. An array literal is a NewArrayInit, so it fell through to
member resolution. ReduceToConstant already evaluates NewArrayInit, so
TryToParseConstant now accepts one whose elements read no lambda parameter.

An array assembled from a compiled query's own members is left alone. The
plan binds each parameter to one of the query's members by matching values,
an array of several members matches none of them, and reducing it would
freeze the planning-time values into every later execution. That shape keeps
failing the way it did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cution

Builds on the inline-array Contains() fix. `new[] { First, Second }.Contains(x.Name)` in a
compiled query used to be refused, because the plan binds each parameter to one query
member by matching values and an array assembled from two members matches neither:
reducing it would have frozen the planning-time values into every later execution.

CompiledQueryPlan.MatchParameters now matches such an array element by element. Each
element that equals a scalar member's planning value is re-read from that member on every
execution; an element no member accounts for is a literal and keeps its value. The setter
clones the planning array, so it builds no array type at runtime. That also fixes
`x.Name.IsOneOf(new[] { First, Second })`, which has silently kept its planning-time
values on master.

The compiled-query guard in isArrayLiteralValue is gone, and the test that pinned the
refusal now runs the query twice with different values: for string, int and enum members
(both EnumStorage modes), for IsOneOf, and for a member mixed with a literal. All six fail
without the element binding and pass with it.

CoreTests: the diagnostics criteria shape matrix expected Dynamic LINQ's inline
`Score in (1, 2, 3)` / `CustomerName in ("Ann", "Bob")` to be refused. Both are array
literals to LINQ and now translate correctly, so the expectations are Correct.

Co-authored-by: Marko Lahma <marko.lahma@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FSnat2TKnusp6MdEZgpHj
@jeremydmiller
jeremydmiller merged commit cece88f into master Oct 19, 2026
32 checks passed
@jeremydmiller
jeremydmiller deleted the claude/eager-volta-93f7i6 branch October 19, 2026 18:30
This was referenced Oct 20, 2026
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.

3 participants