Skip to content

Compiled queries: plan from a generated template, and refuse values computed from the query object (#5711, options 2 and 3) - #5717

Merged
jeremydmiller merged 4 commits into
masterfrom
claude/5711-compiled-query-binding
Oct 19, 2026
Merged

jeremydmiller merged 4 commits into
masterfrom
claude/5711-compiled-query-binding

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Part of #5711. This covers options 2 and 3 only. The structural fix (option 1: bind by reading the query object instead of matching values) stays open on the issue.

Problem

A compiled query's plan binds each SQL parameter to a query member by matching values. A parameter whose value equals a member's planning value is re-read from that member on every execution, and any other parameter is treated as a literal. Two consequences:

  1. A computed value is frozen. x.Name == First + Second, x.Name == Name.ToUpper() or new List<string> { Name }.Contains(x.Name) produce a value that equals no single member. The plan therefore treats it as a literal, and every later execution silently reuses the value the plan was built with.
  2. The caller's instance can be the template. When every member of the caller's instance had a distinct value, the plan was built from that instance rather than from a generated one. A value the caller set could then coincide with a literal elsewhere in the query.

The change

Option 2: always plan from a generated template (CompiledQueryPlan.CreateQueryTemplate / TryCreateUniqueTemplate).

  • The plan always tries a generated instance with unique values first. An IQueryPlanning type still supplies its own values.
  • It falls back to the caller's instance only if no template can be built and the caller's values are unique. Otherwise it throws InvalidCompiledQueryException.
  • Generated values are now written into every member. Before, a fresh instance with a member left at null or its default could pass the uniqueness check while that member matched nothing.
  • The DateTime and DateTimeOffset finders now produce distinct values for each member. Before, they handed out the same value every time.

Option 3: refuse a value computed from the query object (new QueryObjectReads, called from QueryCompiler.BuildQueryPlan).

Every largest sub-expression of QueryIs() that reads the query object, and uses no lambda parameter, is evaluated on the client. Each one has to bind to a member, or planning refuses it, naming the expression. These shapes are allowed:

The message:

Invalid compiled query type …. Its QueryIs() uses query.First + query.Second, a value computed from the query object rather than one of its members read directly. … Compute the value before running the query and expose it as a member of its own, or run this as a normal LINQ query instead of a compiled one. See #5711

Behaviour change

A compiled query that used to run with a frozen value now throws when it is planned. That is intended: it was returning wrong results silently. docs/documents/querying/compiled-queries.md describes the limitation and the workaround (a computed property on the query type, as the existing SkipCount sample already does).

Binary compatibility

No public signature changes. QueryObjectReads is internal, and the template changes are inside existing private and internal members.

Tests

LinqTests/Bugs/Bug_5711_compiled_query_parameter_binding.cs: 10 tests, each running the query twice with different values where it matters.

Test Pins
scalar and array literal collisions the generated template is what the plan binds against
concatenation, ToUpper(), List<T> initializer, a derived local captured in a closure refused, with the expression named
lifted int → int?, (int)enum, a member read through a closure, two DateTime members still bind

Things the full runs caught along the way, all now covered:

  • the null-member template;
  • enum comparisons under EnumStorage.AsString;
  • the net10 span wrapper.

Local, net10.0, Release, against PostgreSQL:

Suite Result
LinqTests 1657 / 1657
CompiledQueryTests 9 / 9
ValueTypeTests 343 / 343
Marten.AspNetCore.Testing 116 / 116
DocumentDbTests 1153 passed, 1 skipped
CoreTests 820 passed, 8 skipped

markdownlint and cspell are clean on the docs page.


Generated by Claude Code

…puted from the query object

A compiled query's plan binds each SQL parameter to the query member whose
value it carries. Planning from the caller's own instance let a literal that
happened to equal a member's first value bind to that member, and a value
computed from members (First + Second, Name.ToUpper(), a list initializer)
matched no member and froze at its planning value. Both returned wrong rows
on every later execution, with no error.

- Always plan from a generated unique-value template, falling back to the
  caller's instance only when a template cannot be built.
- Refuse at plan time any sub-expression of QueryIs() that reads the query
  object without being a direct member read (or a value-preserving
  conversion of one, or an inline array of them).
- The DateTime and DateTimeOffset planning values were all the same value,
  so a query with two date members could not be planned from a template.
- A template built from constructor arguments now stores its own values
  before MatchParameters reads them.

Part of #5711 (options 2 and 3); structural binding stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FSnat2TKnusp6MdEZgpHj
A freshly constructed query passed the template's uniqueness check whenever
its members differed from one another, so a query with one string member
planned with that member null. A null matches no parameter, so the value
froze. Before #5711 this path was only reached when the caller's own values
were not unique; planning from the template every time exposed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FSnat2TKnusp6MdEZgpHj
x.Color == Color compiles to Convert(x.Color, Int32) == Convert(Color, Int32).
The parser strips that conversion and binds the enum member through the
enum storage, so the refusal must not judge it by its integer value, which
under string storage matches nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FSnat2TKnusp6MdEZgpHj
@jeremydmiller
jeremydmiller merged commit fc9deef into master Oct 19, 2026
32 checks passed
@jeremydmiller
jeremydmiller deleted the claude/5711-compiled-query-binding branch October 19, 2026 21:23
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.

2 participants