(MINOR): Reference query values through the query object so ORMs parameterize them - #68
Merged
Merged
Conversation
Adds the shared machinery attributes will use to supply query values, without yet changing any attribute's behavior. BuildValueExpression emits the query value as a member access on the query object -- the shape the C# compiler produces for a captured variable -- which ORMs such as EF Core lift into a SQL parameter. It converts to the type the call site needs only when the declared query property type is not assignable to it, preserving what the previous Expression.Constant(value, targetType) calls were doing for cases like a Guid? query property against a Guid target. InlineValue opts a property back into a literal constant, for cases where inlining is preferable, such as a predicate over badly skewed data where a plan built for the specific value beats a reused one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pK9kvAcxFsZ3KkYQcEZGP
Routes SimpleComparisonQueryAttribute (Equals, NotEquals, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual) and the string attributes through BuildValueExpression, so their values are referenced through the query object instead of inlined as constants. QueryExpressionBuilderTests asserts exact expression strings, so its expectations move to the new shape. TestQuery and TestAdvancedQuery become plain classes: a record's generated ToString dumps every property value, which the expression printer would then render inside the constant node, making the assertions long and value-dependent. Nothing relied on their value semantics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pK9kvAcxFsZ3KkYQcEZGP
Routes ContainsQueryAttribute and AnyOfQueryAttribute through BuildValueExpression. NoneOfQueryAttribute needs no change; it wraps AnyOfQueryAttribute's result in a Not. The AnyOf paths now request IEnumerable<T> for the element type rather than relying on the runtime type of the collection, so a query property declared as IEnumerable or object is converted explicitly instead of working by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pK9kvAcxFsZ3KkYQcEZGP
Covers the parameterized expression shape for each attribute family, the conversion applied to a nullable query property, InlineValue emitting literals instead, and both strategies filtering identically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pK9kvAcxFsZ3KkYQcEZGP
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pK9kvAcxFsZ3KkYQcEZGP
PaulTrampert
commented
Sep 6, 2026
The expression printer renders a constant using the value's ToString, so a record fixture prints its full property dump inside the constant node. Interpolating query.ToString() into the expected string handles that without changing the fixtures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pK9kvAcxFsZ3KkYQcEZGP
✅ PR Title Formatted CorrectlyThe title of this PR has been updated to match the correct format. Thank you! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every attribute built its query value with
Expression.Constant. That is the one shape EF Core (and most LINQ providers) will not lift into a SQL parameter — it gets inlined as a literal, soId = '3f2a…'changes with every value. Both the database's plan cache and EF Core's own compiled-query cache are keyed on that text, so neither was doing much work.What changed
QueryAttributegained the shared machinery, so custom attributes get it too:public bool InlineValue { get; set; }— defaults tofalse.protected Expression BuildValueExpression(queryObject, queryProperty, queryValue, valueType = null)— returnsExpression.Property(Expression.Constant(queryObject), queryProperty), the same shape the C# compiler emits for a captured variable. It adds anExpression.Convertonly when the declared query-property type is not assignable to the type the call site needs (theGuid?→Guidcase the oldExpression.Constant(value, targetType)handled). WithInlineValueset it falls back to the original constant.All six value-carrying attributes route through it:
valueTyperequestedSimpleComparisonQueryAttribute(Equals/NotEquals/GreaterThan/…)targetProperty.PropertyTypeStringContainsQueryAttribute,StringStartsWithQueryAttributetypeof(string)ContainsQueryAttributeAnyOfQueryAttribute(andNoneOfQueryAttributevia inheritance)IEnumerable<elementType>NoneOfQueryAttributeneeded no edit — it wrapsAnyOfQueryAttribute's result in aNot.Resulting shape:
EF Core folds each of those evaluatable subtrees,
Convertincluded, into a single parameter, so the SQL text is stable across values and the parameter name comes from the query property (@__Id_0).Opting out
InlineValue = trueon any attribute restores the literal, for cases where inlining is preferable — for example a predicate over badly skewed data where a plan built for the specific value beats reusing one built for a previous value.Reviewing
Five commits, each building and passing tests on its own:
InlineValueandBuildValueExpression— mechanism only, no behavior change.Commit 2 also converts
TestQueryandTestAdvancedQueryfrom records to plain classes.QueryExpressionBuilderTestsasserts exact expression strings, and a record's generatedToStringdumps every property value, which the expression printer renders inside the constant node — the assertions would otherwise be long and value-dependent. Nothing relied on their value semantics.Notes for the release
This is a behavior change for anyone whose provider treats constants and parameters differently. Most will simply see parameterized SQL where they previously saw literals, but a provider that cannot parameterize a given construct would now fail where it previously succeeded;
InlineValue = trueis the escape hatch.Two details worth knowing:
SqlNullabilityProcessorinspects parameter values and emitsIS NULLfor a null parameter, caching SQL keyed on parameter nullability, soIgnoreIfNull = falsewith a null value behaves as before.OPENJSON/array parameter. On EF Core 7 and earlier it still expands toIN (@p0, @p1, …), so the SQL shape varies with the element count — better than varying with every value, but not fully stable.Tests: 34 passing.
🤖 Generated with Claude Code
https://claude.ai/code/session_018pK9kvAcxFsZ3KkYQcEZGP