Repository navigation
Plan analysis: the non-SARGable check reads which side and which table a function or conversion is on (#4518, #4519) - #4554
Merged
Conversation
…hey wrap the column (#4518) The non-SARGable predicate check had four faults, all in the same family: a helper looked at the wrong side of a comparison, or read more of the predicate than the one comparison it should have. CONVERT_IMPLICIT was flagged whenever the predicate contained it, with no check of which side it converts. Data type precedence decides which side SQL Server converts, and it converts the lower-precedence side, so a parameter compared to a wider column gets converted up while the column stays untouched and seekable. The function-on-column-side check (shared by the function-call rule and by ISNULL/COALESCE) split the whole predicate at its first comparison operator. In a compound predicate that put every later comparison, column included, on the earlier function's side, so a parameter-side function in the first conjunct made a real column reference later in the predicate look like it shared that side. LIKE was not recognized as a comparison operator, so a predicate like a pattern-side function before LIKE found no operator, fell back to the assume-the-worst default, and reported the function as wrapping the column. The fix reads only the comparison a function actually belongs to: the text between the nearest AND/OR on either side, split at every parenthesis depth, skipping string literals and bracketed names. The same scoping now covers ISNULL/COALESCE, and LIKE is added to the recognized comparison operators. DetectNonSargablePredicate is split into a plan-node wrapper and an internal DetectNonSargablePattern seam so predicate shapes can be tested directly as strings.
… non-SARGability (#4519)
…rgable # Conflicts: # PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
…rgable # Conflicts: # PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
erikdarlingdata
marked this pull request as ready for review
September 28, 2026 05:30
This was referenced Sep 28, 2026
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.
Fixes #4518
Fixes #4519
Part of #4511
Why
The non-SARGable predicate check had several related faults. Each one produced a Non-SARGable Predicate warning for a predicate that can actually seek, or missed one that could not, and the accompanying advice then pointed at the wrong cause.
CONVERT_IMPLICITwas flagged whenever the predicate contained it, with no check of which side it converts. Data type precedence decides which side SQL Server converts — it converts the LOWER-precedence side — so a parameter compared to a wider-precedence column is converted up while the column itself stays untouched and seekable.ISNULL(@p, 0)on the parameter side was reported as "wrapping a column" even though it is a runtime constant that seeks fine.LIKEwas not recognized as a comparison operator at all, so a pattern-side function beforeLIKEfound no operator, fell back to the assume-the-worst default, and the pattern's function was reported as wrapping the column.[t].[col]), with no idea which table a name belonged to. An unaliased table variable column renders with no qualifier at all — the same shape as a parameter — so a function or conversion on it was never flagged. And a Nested Loops join passes a value into its inner scan as an outer reference; a function on that value wraps the outer input, not the scan's own column, but the old check counted every column-shaped name as the scan's own and flagged it anyway.What changes
Ports PerformanceStudio
1c17b06(erikdarlingdata/PerformanceStudio#436/#437),6314f2c(erikdarlingdata/PerformanceStudio#556),1016100(erikdarlingdata/PerformanceStudio#561),9a04629(erikdarlingdata/PerformanceStudio#564) and1374b75intoPlanAnalyzer.cs, matching PerformanceStudiomain.ConvertImplicitWrapsColumnreads what is INSIDE theCONVERT_IMPLICITcall, using balanced-parenthesis argument extraction (the target type can carry its own parens —numeric(18,0),varchar(50)), instead of a bare text-contains check.IsFunctionOnColumnSidenow reads only the one comparison a function belongs to: the text between the nearestAND/ORon either side, split at every parenthesis depth so a parenthesizedORgroup comes apart too, and never inside a string literal or a bracketed name. The comparison-operator pattern gainsLIKE.ScanIdentity(the scan's alias, its table, whether it's a table variable, and the bare outer references a Nested Loops ancestor passes it) is built once per scan and threaded through the checks above via a newIsColumnReference(text, identity)helper. It reads a dotted name's owner (the alias or table right before the column) and only counts it as the scan's own column when the owner matches. A bare, unqualified name on an unaliased table variable's own scan now also counts as a column, unless it is a parameter, an optimizer expression, or a name the identity lists as another table variable's outer reference.CleanTempTableNamebefore comparing it against the scan's own (already-cleaned) name, so a predicate spelling the full name still matches.DetectNonSargablePatternis split fromDetectNonSargablePredicate(a plan-node wrapper), so predicate shapes can be tested directly as ScalarString text.PM has no
AnalyzerConfig, so thecfg.IsRuleDisabled(N)guards from the PS commits are left out. PM keeps onePlanAnalyzer.csfile rather than PS'sPlanAnalyzer.Detection.cspartial, so the new helpers land in the existing file next to the code they extend.ShowPlanParser.CleanTempTableNamechanges fromprivatetointernalsoPlanAnalyzercan call it, matching PS.Target-shape check: after the port,
ConvertImplicitWrapsColumn(string, ScanIdentity? = null)andIsFunctionOnColumnSide(string, Match, ScanIdentity? = null)match PSmain'sPlanAnalyzer.Detection.csapart from thecfgguards PM omits — same signatures, same helper call structure, sameIsColumnReferenceseam.Test plan
New file
Darling/Darling.Tests/PlanSync4518Tests.cs(seven facts againstPlanAnalyzer.DetectNonSargablePattern) andDarling/Darling.Tests/PlanSync4518EndToEndTests.cs(five facts throughShowPlanParser.Parse+PlanAnalyzer.Analyze) cover faults 1–4. The five end-to-end facts use only members that exist on dev and fail there at runtime (a standalone probe onorigin/dev); the seven seam facts are compile-only on dev (DetectNonSargablePatternis new). Mutation: replacing the same-depthAND/ORscoping inIsFunctionOnColumnSidewith the whole predicate fails the scoping fact (Actual: "Function call (UPPER) on column").New file
Darling/Darling.Tests/PlanSync4519Tests.cscovers fault 5, end to end through the real parser and analyzer on synthetic plan XML:UnaliasedTableVariableBareColumn_IsFlagged_EndToEnd: a table variable scan with predicateupper([col])=[@p](bare, unqualified) — now flagged.OuterReferenceFunctionOnOuterSide_NotFlagged_EndToEnd: a Nested Loops inner scan predicate[t].[a]=upper([o].[x]), where[o].[x]is the outer reference — no longer flagged.TempTableFullTempdbName_OwnerComparisonMatches_EndToEnd/TempTableFullTempdbName_AnotherTempTable_NotFlagged_EndToEnd: a#temptable scan whose predicate names the table by its full tempdb (padded, hex-suffixed) name — the owner comparison cleans it and matches the scan's own column, and does not falsely match a different temp table's full name.RED on dev (runtime): all three positive facts fail on
origin/dev—UnaliasedTableVariableBareColumn_IsFlagged_EndToEnd(Assert.True: actual False — the old check never sees the bare column),OuterReferenceFunctionOnOuterSide_NotFlagged_EndToEnd(Assert.False: actual True — the old check counts the outer reference as the scan's own column),TempTableFullTempdbName_AnotherTempTable_NotFlagged_EndToEnd(Assert.False: actual True — the old check has no ownership concept at all, so any dotted name matches). Built and run in a detached worktree atorigin/dev(0524634fa), sinceShowPlanParser/PlanAnalyzer's public surface is unchanged there.Mutation: temporarily made the dotted-name branch of
IsColumnReferencereturntrueunconditionally (any dotted name counts, ownership ignored — the exact old-behavior fault this port fixes), rebuilt, reranPlanSync4519Tests:OuterReferenceFunctionOnOuterSide_NotFlagged_EndToEndandTempTableFullTempdbName_AnotherTempTable_NotFlagged_EndToEndboth went RED withAssert.False() Failure: Actual: True. Reverted, rebuilt, reconfirmed green.Run (after merging current dev): every
PlanSync*class plus the plan-analysis classes passed, 0 failed.DarlingAnalysisPipelineTestsandDarlingMcpPlanToolsLivePostgresTestsneed a live store and run in CI; before the merge, the same list ran 246/0 with a live store.Build:
Darling.Tests(Release,EnableWindowsTargeting=true) — 0 errors, 0 warnings.Lite.Tests(Windows-only, cannot execute on macOS) — 0 errors, 0 warnings.Checked against PerformanceStudio
main:ComparisonContaining,ExtractBalancedArguments,ConvertImplicitWrapsColumn,IsFunctionOnColumnSide,BuildScanIdentityandCollectBareOuterReferencesare the same code;IsColumnReferenceandIsTableVariablediffer only in comments and a'@'/"@"literal.The temp-table facts use SQL Server's real tempdb name shape (
#t+ underscores + a 12-digit suffix).CHANGELOG
SECTION: Fixed
ENTRY: - Plan analysis: the non-SARGable predicate check now knows which table a function, conversion or
ISNULL/COALESCEcall is wrapping, so it stops flagging a Nested Loops outer reference or a parameter-side conversion, recognizesLIKEas a comparison, and now also catches a function or conversion on an unaliased table variable's own column ([#4554]) - Each previously produced a Non-SARGable Predicate warning for a predicate that could actually seek (or, for the table-variable case, missed a real one), and pointed at the wrong cause.REF: [#4554]: #4554