Put the parameter values back where the analysis is built (#482) - #483
Conversation
… copy paths (#482) #467 substituted at Copy Query Text, Open in Query Editor and the statements grid, and the reporter came straight back with "still shows parametrized in Human and Robot Advice". Both advice buttons, the HTML export, the comparison report and every MCP tool read their statement text out of ResultMapper, so that is where the substitution belongs. StatementResult now carries both forms. StatementText is runnable; parameterized_statement_text carries the plan's own record, present only when something was substituted. get_repro_script reads the parameterized form on purpose — it wraps that body in sp_executesql with a parameter list read out of the same plan, and a body with the literals already inlined would declare parameters it never uses. Statement pairing was checked first and does not key on text: ComparisonFormatter matches on QueryHash and falls back to position, so two runs of one query with different values still pair. A test pins that. Substitution also grew an assignment-target check. compile_memory_exceeded_plan is "SELECT @job_name = name, @owner_sid = owner_sid" with both compiled values NULL, and writing the value over the target gave "SELECT NULL = name" — a clipboard wart under #467, but this change would have made it the default rendering in advice, exports and MCP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016a1AnKAHwcALrwdYVVrpgR
| while (forward < text.Length && char.IsWhiteSpace(text[forward])) | ||
| forward++; | ||
|
|
||
| if (forward >= text.Length || text[forward] != '=') |
There was a problem hiding this comment.
IsAssignmentTarget only recognizes plain = as an assignment operator, so T-SQL's compound assignment forms (+=, -=, *=, /=, %=, &=, ^=, |=) fall through as reads and get overwritten with their own value — the same failure mode this function exists to prevent.
Trace: SET @a += 1 with @a's compiled value 5. At @a, forward lands on + (not =), so the function returns false at line 143-144 before ever reaching the lead-in check. Apply then substitutes, producing SET 5 += 1 — broken text that would show up in Advice for Humans/Robots, the HTML export, and the comparison report (though not get_repro_script, which prefers ParameterizedStatementText).
Plans that capture a bare SET @var += ... as a StmtSimple (e.g. inside a procedure body) will hit this. Worth at least checking text[forward] is '=' or '+' or '-' or '*' or '/' or '%' or '&' or '^' or '|' followed by = (and not ==, which doesn't exist in T-SQL anyway) so compound assignments are recognized too.
|
Reviewed. The seam analysis in the PR description checks out — One finding, left inline: No other correctness, security, or convention issues found. Test coverage for the new behavior is thorough — the thirteen new tests hit assignment/non-assignment shapes, the four-consumer fan-out, the QueryHash-pairing safety property, and the CLI-contract-preserving no-op case. |
Closes #482. Out of #467, out of #466.
What was wrong
@joshdbe confirmed #467 worked and then said the rest of the app hadn't moved:
He's right, and it's the same mistake as #447. #467 fixed the three consumers I
happened to be looking at — Copy Query Text, Open in Query Editor, and
Ctrl+Con the statements grid — and every other consumer went on handing out
@0.There aren't two bugs here, there's one seam. Advice for Humans is
TextFormatter.Format(analysis). Advice for Robots isJsonSerializer.Serialize(analysis). Both readStatementResult.StatementText,which comes from
ResultMapper.MapStatement, and so does the HTML export, thecomparison report,
PlanOperations' ranked-operator and warning labels, and theMCP tools. Fixing one place fixes all of them, and the MCP tools are the ones
that mattered most: handing a model
@0and no values is handing it a questionit cannot answer.
The parser's
PlanStatementis deliberately untouched. The analyzer's rulesregex over that text —
OPTIMIZE FOR UNKNOWN,NOT IN, theMAXDOP 2hint,the row-goal cause — and the properties panel is a view of the XML. Neither
should start reading manufactured literals.
WarningBaseline.txtdid not move,which is the evidence that the change landed at the right layer rather than a
comment claiming it did.
The question the issue told me to answer first
Does anything match, pair, key or dedupe on statement text? No.
ComparisonFormatter.MatchStatementspairs onQueryHash, then falls back toposition; statement text is only ever printed, truncated to 500 characters.
Nothing else in the repo groups, keys or hashes on it. So the failure mode I was
warned about — two runs of one query with different parameter values no longer
matching each other — does not exist here. There is a test that pins it anyway,
because "it pairs on QueryHash" is a fact about today's code and this is exactly
the sort of thing that gets quietly rewritten.
Is the CLI's JSON a contract? Yes, and the repo says so in its own words.
HistoricalCliContractTestshashesanalyze --compactagainst a pinned SHA256,and the comment on that constant says it has rolled twice, both times additively,
"so a consumer reading fields by name is unaffected — but anything diffing or
hashing whole output sees different bytes, which is exactly what this constant
exists to make somebody decide on rather than discover."
That alone would have been arguable. What settled it is a consumer inside this
repo that genuinely needs the parameterized form:
get_repro_scriptfalls backto
StatementTextand hands it toReproScriptBuilder, which wraps the body insp_executesqlwith a parameter list read out of the same plan. A body with theliterals already inlined would declare parameters that appear nowhere in it, and
the plan it produced would be the constant-folded one rather than the
parameterized compile the repro script exists to reproduce. That is precisely
the "something was quietly relying on it" case, and it is not hypothetical.
So: both forms
StatementTextis the runnable text.parameterized_statement_textcarries theplan's own record, and is null unless something was actually substituted —
which is nearly every statement, so the JSON does not grow for plans this does
not touch.
get_repro_scriptreaches for it and falls through toStatementText,which is the same string whenever there was nothing to substitute.
The pinned CLI hash is still green, and not by luck:
row_goal_plan.sqlplanhasno
ParameterList, so itsstatement_textis byte-identical and the new key isabsent. A test pins both halves of that.
I did consider the other shape — leave
statement_textalone and addrunnable_statement_text— which would have been purely additive. I didn't takeit, for the reason this issue exists: it would leave
statement_text, theobvious field, still saying
@0, and every future consumer would default to thewrong one. That is how #467 and #447 both shipped incomplete. The right default
belongs at the seam.
One thing the issue didn't ask for, which this change forced
compile_memory_exceeded_plan.sqlplanisSELECT @job_name = name, @owner_sid = owner_sid FROM msdb.dbo.sysjobs_view WHERE (job_id = @job_id),and its ParameterList records both assigned variables with a compiled value of
NULL. Substituted blindly that readsSELECT NULL = name, NULL = owner_sid—not merely unrunnable but quietly misleading, because it now looks like a
comparison.
That defect is #467's, not this PR's. But it was reachable only by explicitly
choosing "Copy Query Text (with values)", and this change makes it what the
advice, the exports and the MCP tools show by default. Shipping that knowingly
isn't on, so
ParameterSubstitutiongrew an assignment-target check: aparameter followed by a lone
=and preceded bySELECT,SET, or a listcomma is being assigned to, not read from, and keeps its name.
@job_idin theWHEREstill gets its value.It is three lead-ins and a forward scan, not a parser, and it is deliberately
narrow. A parameter on the right of an
=is a read. A parameter followed by>=,<=,<>or!=is a comparison, because the scan forward meets thatoperator's own character rather than the
=.SELECT VoteTypeId = @VoteTypeId— an alias on the left, the parameter on the right, which is the shape
param-sniffing-posttypeid2carries — is still substituted. All four of thosehave tests, and the two guarding against over-suppression were proven red
against an over-eager rule rather than against no rule at all, since no rule
passes them trivially.
Also: the web viewer links Core files by hand
PlanViewer.Web.csprojlists the Core sources it compiles one by one, soResultMapperreaching forParameterSubstitutionbroke the Blazor build whilethe full test suite stayed green. The file is now on the list. It is pure string
work with no WASM-hostile dependencies, and the web viewer renders the same
AnalysisResult, so it gets the fix too.What this does not do
Textshows what the planrecords, next to
ParameterizedText, and that panel stays a view of the XML.ReproScriptBuilder's parameter-name check.@0…@6fail its
^@[\p{L}_@#$]identifier test and get dropped, so aforced-parameterization plan never reaches its
sp_executesqlbranch at all.That's arguably wrong —
sp_executesql N'…', N'@0 varchar(8000)', @0='123456'is legal — but it is a separate question from this one and I left it alone.
get_plan_parametersnow shows the substituted statement next to theparameter list it is describing, so the
@0anchor is gone from that label.The values are right there in the same object, so it reads fine, but it is a
deliberate call rather than an oversight.
Tests
Thirteen new, in two files: the analysis-level consumers in
AnalysisParameterSubstitutionTests, the substitution rule itself alongside#467's own cases in
ParameterSubstitutionTests.Every one was proven red first, against four separate reverts, because a test
that passes either way is how #467 shipped incomplete:
the preservation pins, which is the point of them.
and so does one of Offer to put parameter values back when copying a statement out of a plan #467's existing tests, which is a decent sign the wrong
rule really is wrong.
script reading
StatementText) → the 2 preservation pins fail. Those are redagainst exactly the tempting implementation, which is the only counterfactual
that makes them worth having.
Suite: 392 on
origin/dev(018a82f), 405 here, 0 failed, 2 skipped(Windows-only), four consecutive full runs, ~20s each, no flakes.
WarningBaseline.txtunchanged. Full solution builds clean.🤖 Generated with Claude Code
https://claude.ai/code/session_016a1AnKAHwcALrwdYVVrpgR