Skip to content

(MINOR): Reference query values through the query object so ORMs parameterize them - #68

Merged
PaulTrampert merged 6 commits into
mainfrom
parameterize-query-values
Sep 6, 2026
Merged

PaulTrampert merged 6 commits into
mainfrom
parameterize-query-values

Conversation

@PaulTrampert

Copy link
Copy Markdown
Owner

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, so Id = '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

QueryAttribute gained the shared machinery, so custom attributes get it too:

  • public bool InlineValue { get; set; } — defaults to false.
  • protected Expression BuildValueExpression(queryObject, queryProperty, queryValue, valueType = null) — returns Expression.Property(Expression.Constant(queryObject), queryProperty), the same shape the C# compiler emits for a captured variable. It adds an Expression.Convert only when the declared query-property type is not assignable to the type the call site needs (the Guid?Guid case the old Expression.Constant(value, targetType) handled). With InlineValue set it falls back to the original constant.

All six value-carrying attributes route through it:

Attribute valueType requested
SimpleComparisonQueryAttribute (Equals/NotEquals/GreaterThan/…) targetProperty.PropertyType
StringContainsQueryAttribute, StringStartsWithQueryAttribute typeof(string)
ContainsQueryAttribute target collection's element type
AnyOfQueryAttribute (and NoneOfQueryAttribute via inheritance) IEnumerable<elementType>

NoneOfQueryAttribute needed no edit — it wraps AnyOfQueryAttribute's result in a Not.

Resulting shape:

Param_0 => Param_0.Id == Convert(value(PersonQuery).Id, Guid)
Param_0 => Param_0.Name.Contains(value(PersonQuery).NameContains)
Param_0 => value(PersonQuery).Names.Contains(Param_0.Name)
Param_0 => Param_0.Tags.Intersect(value(PersonQuery).Tags).Any()

EF Core folds each of those evaluatable subtrees, Convert included, 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 = true on 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.

[EqualsQuery(InlineValue = true)]
public bool? IsDeleted { get; set; }

Reviewing

Five commits, each building and passing tests on its own:

  1. Add InlineValue and BuildValueExpression — mechanism only, no behavior change.
  2. Comparison and string attributes.
  3. Collection attributes.
  4. New tests.
  5. Docs.

Commit 2 also converts TestQuery and TestAdvancedQuery from records to plain classes. QueryExpressionBuilderTests asserts exact expression strings, and a record's generated ToString dumps 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 = true is the escape hatch.

Two details worth knowing:

  • Null semantics are preserved. EF Core's SqlNullabilityProcessor inspects parameter values and emits IS NULL for a null parameter, caching SQL keyed on parameter nullability, so IgnoreIfNull = false with a null value behaves as before.
  • Collections improve most on EF Core 8+. There a parameterized collection becomes a single OPENJSON/array parameter. On EF Core 7 and earlier it still expands to IN (@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

PaulTrampert and others added 5 commits September 5, 2026 22:58
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
Comment thread PTrampert.QueryObjects.Test/TestAdvancedQuery.cs Outdated
Comment thread PTrampert.QueryObjects.Test/TestQuery.cs Outdated
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
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

✅ PR Title Formatted Correctly

The title of this PR has been updated to match the correct format. Thank you!

@PaulTrampert
PaulTrampert merged commit 42d438e into main Sep 6, 2026
8 checks passed
@PaulTrampert
PaulTrampert deleted the parameterize-query-values branch September 6, 2026 03:20
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