Detect a non-SARGable function or conversion on an unaliased table-variable column - #565
Conversation
…riable column Rule 12 relies on ColumnReferenceRegex, which requires a dotted name ([table].[col]) to recognize a column. An aliased table-variable column renders dotted through the alias (e.g. "abs(@tv.[X] as [v].[X])=(1)"), so that case already worked. An unaliased one renders as a bare bracketed name with no dotted qualifier at all (e.g. "abs([X])=(1)"), confirmed on SQL Server 2016, 2017, 2019, 2022 and 2025. The regex cannot see that as a column on its own, since the same shape could be a parameter or an expression, so the function call and the implicit conversion in the fixtures below never got flagged. Add IsTableVariable(node) and thread an isTableVariableScan flag from DetectNonSargablePredicate through DetectNonSargablePattern into a new shared IsColumnReference helper, used by both ConvertImplicitWrapsColumn and IsFunctionOnColumnSide. On a confirmed table-variable scan, a bare name now also counts as a column, unless it is a parameter or variable, an optimizer-generated expression, or a function call. Every other scan keeps today's behavior, since the flag defaults to false. Also corrects two comments that claimed an unaliased table-variable column renders as [@tv].[col] — no SQL Server version does this. Closes #561
The rewritten ColumnReferenceRegex comment said an aliased table-variable column failed the old pattern. It did not: the alias part [v].[X] has no @, so the first cut matched it. The [@tv].[col] shape that the first cut was blamed for never occurs on SQL Server 2016 through 2025, so the history goes and the comment states only what each version renders. IsColumnReference now names the case it still reads wrong: a bare outer reference from another unaliased table variable (#564). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ZVrq8tpA2DBPEFEqahFK6
|
Reviewed the diff ( Things that check out:
No correctness, security, or convention issues found. Nit-level only: the "bare name = column" heuristic would misread a literally bracket-quoted column named like |
Problem
Rule 12 (Non-SARGable Predicate) reads a comparison's ScalarString to decide which side a function or an implicit conversion sits on. It calls this a column only when
ColumnReferenceRegexfinds a dotted name, such as[table].[col].An aliased table-variable column renders dotted, through the alias, so that case already worked:
SELECT v.X FROM @tv AS v WHERE ABS(v.X) = 1givesabs(@tv.[X] as [v].[X])=(1).An unaliased table-variable column renders as a bare bracketed name with no dotted qualifier at all:
SELECT X FROM @tv WHERE ABS(X) = 1givesabs([X])=(1). I confirmed this on SQL Server 2016, 2017, 2019, 2022 and 2025. No version renders[@tv].[col], soColumnReferenceRegexcan never match it, and rule 12 never flags the function or the conversion. Rule 11 (Scan With Predicate) then fires the generic residual-predicate warning instead, which does not name the fix.Change
src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs:IsTableVariable(PlanNode node), a helper the scan'sObject Table="[@tv]"attribute test already used inline inCheckForTableVariables.CheckForTableVariablesnow calls it too, instead of repeating the check.IsColumnReference(string text, bool isTableVariableScan). It checksColumnReferenceRegexfirst, exactly as before. When that fails andisTableVariableScanis true, it also reads a bare bracketed name as a column. It excludes 3 shapes: a parameter or a variable ([@p1]), an optimizer-generated expression ([Expr1003]), and the name of a function call (followed by(). It reusesBracketedNameRegex, the same regexReadsAnotherInputalready uses to walk names in a ScalarString.ConvertImplicitWrapsColumnandIsFunctionOnColumnSidenow callIsColumnReferenceinstead ofColumnReferenceRegexdirectly, each with a newisTableVariableScanparameter that defaults tofalse.DetectNonSargablePatterntakes the same flag, also defaulted tofalse, and passes it to both.DetectNonSargablePredicate(node)computesIsTableVariable(node)and passes the real flag. This is the only caller that does not use the default, so every other scan keeps today's behavior exactly.src/PlanViewer.Core/Services/PlanAnalyzer.cs:ExpressionColumnRegex(^\[Expr\d+\]$) for the expression-name exclusion above.ColumnReferenceRegex, which claimed an unaliased table-variable column renders as[@tv].[col]. It does not, on any of the five SQL Server versions I checked. The comment now gives the real aliased and unaliased shapes.ConvertImplicitWrapsColumn.Rule 11 already skips its own warning when rule 12 has already flagged the predicate (
GetNonSargableReason(node, cfg) == nullin its condition). Once rule 12 recognizes the bare column, "Scan With Predicate" stops firing on these scans. This needed no separate change.A second commit corrects the history in the
ColumnReferenceRegexcomment. The first commit said that an aliased table-variable column failed the old pattern. It did not, because the alias part[v].[X]has no@, so the old pattern matched it.Known and accepted, not fixed here
Rule 12 already reads an outer reference as a column: on a correlated inner scan,
u.X = ABS(t.a)renders#u.[X] as [u].[X]=abs(#t.[a] as [t].[a])and gets flagged, because#t.[a] as [t].[a]is dotted. This change extends that same reading to a bare outer reference from another unaliased table variable. #564 covers both cases, with plans captured for each rendering.Tests
Added to
tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs.Corrected, using real shapes instead of the fictional
[@tv].[col]:Rule12f_NonSargable_AliasedTableVariableColumnConversion_IsFlaggedRule12f_NonSargable_UnaliasedTableVariableColumnConversion_IsFlaggedWithTableVariableFlagRule12f_NonSargable_UnaliasedTableVariableColumnConversion_NotFlaggedWithoutTableVariableFlagRule12f_NonSargable_UnaliasedTableVariableParameterSideConversion_IsNotFlaggedString-level tests on
DetectNonSargablePattern. With the flag on,abs([X])=(1)andCONVERT_IMPLICIT(nvarchar(20),[S],0)=[@n]are flagged. Still with the flag on,[X]=abs([@i]),abs([Expr1003])=(1), and[X]=upper('[Y]')are not flagged. With the flag off,abs([X])=(1)is not flagged.Fixture tests, using the 3 new
.sqlplanfiles:table_variable_unaliased_function_plan.sqlplanandtable_variable_implicit_conversion_plan.sqlplannow get "Non-SARGable Predicate" and no "Scan With Predicate".table_variable_aliased_function_plan.sqlplan's warnings do not change.All 96 tests in
PlanAnalyzerTestspass (85 before this change, 11 added).Mutation results
Four mutations, each restored before the next, each rebuilt with
--no-incremental:IsColumnReferencealways reads a bare name as a column): failedRule12f_NonSargable_UnaliasedTableVariableColumnConversion_NotFlaggedWithoutTableVariableFlagandRule12h_NonSargable_BareColumnFunction_NotFlaggedWithoutTableVariableFlag.[Expr...]not excluded: failedRule12h_NonSargable_FunctionOnExpressionColumn_NotFlaggedEvenWithTableVariableFlag.[@...]not excluded: failedRule12f_NonSargable_UnaliasedTableVariableParameterSideConversion_IsNotFlaggedandRule12h_NonSargable_FunctionOnParameterSide_NotFlaggedEvenWithTableVariableFlag.DetectNonSargablePatternintoConvertImplicitWrapsColumn: failedRule12h_NonSargable_BareColumnImplicitConversion_IsFlaggedWithTableVariableFlagandRule12h_NonSargable_ImplicitConversionPlan_GetsNonSargableNotScanWithPredicate.Baseline diff
Regenerated both baselines from empty.
WarningBaseline.txtgained exactly 3 new sections, one per new fixture, and changed nothing else. The 2 function-plan fixtures each show a new "Non-SARGable Predicate" line and no "Scan With Predicate" line.ComparisonBaseline.txtgained a self-comparison section for each of the 3 new fixtures (each comparing the fixture to itself). This characterization test also pairs each fixture with its alphabetical neighbor. Inserting 3 new file names shifted a few neighbor pairings. For example,spill_plan.sqlplan vs table_variable_plan.sqlplanbecamespill_plan.sqlplan vs table_variable_aliased_function_plan.sqlplan. This is mechanical churn from the new file names, not a change to any existing fixture's own analysis. No existing fixture's self-comparison changed.Full suite
I merged both
fix/562-expensive-operator-floorand this branch into a throwaway branch offorigin/dev. I ran the fullPlanViewer.Core.Testsproject once there, including the headless UI tests. Totals: 758 total, 757 passed, 0 failed, 1 skipped, in 1m 11s. The 1 skip is a pre-existing, unrelated Windows/WAM contract pin (EntraInteractiveAuthTests).Closes #561
🤖 Generated with Claude Code
https://claude.ai/code/session_017ZVrq8tpA2DBPEFEqahFK6