Status: Reviewed (deep-dive v1)
Stack note: Warehouse usp_* procs (SQL-12) sometimes need flexible filters — do it with sp_executesql + parameters, never string-glued user input. Blazor/PS still call procs; dynamic SQL stays inside SQL.
Pairs with: SQL-12 (procs), SQL-13 (TVP / #temp instead of CSV), SQL-05 (security).
Goal: When dynamic SQL is justified, how to parameterize it, and how not to get owned.
| Need | Prefer | Avoid |
|---|---|---|
| Optional filters on a board proc | Static SQL with (@p IS NULL OR col = @p) |
Building WHERE from UI strings |
| Truly optional columns/order / search grids | sp_executesql + allowlisted identifiers |
Concatenating raw column names from client |
| Multi-id list | TVP (SQL-13) | IN (' + @csv + ') |
| Run same text many times | sp_executesql (plan reuse) |
EXEC(@sql) with literals baked in |
| Admin one-off in SSMS | Whatever’s clear | Shipping that pattern to Blazor |
Default: If static SQL works, don’t go dynamic.
EXEC (@sql)DECLARE @sql nvarchar(max) =
N'SELECT * FROM dbo.Alert WHERE Title = ''' + @Title + N'''';
EXEC (@sql); -- injection + poor plan reuse
sp_executesql + parametersDECLARE @sql nvarchar(max) =
N'SELECT AlertId, Title, Status
FROM dbo.Alert
WHERE (@Status IS NULL OR Status = @Status)
AND (@Title IS NULL OR Title LIKE @Title)';
EXEC sys.sp_executesql
@sql,
N'@Status varchar(16), @Title nvarchar(256)',
@Status = @Status,
@Title = @Title; -- pass N'%foo%' from caller if needed
Why better: Values are parameters (type-safe, escaped by engine); plans can reuse; injection surface shrinks to identifiers you still must allowlist.
@Status, dates, ids) QUOTENAME(@name) only after allowlist pass EXECUTE AS elevation unless designed and audited EXECUTE on proc only — not db_owner EXEC ( and + @ in prod procs DECLARE @orderBy nvarchar(200) =
CASE @Sort
WHEN 'severity' THEN N'sev.SortOrder ASC, a.FirstSeenAt ASC'
WHEN 'age' THEN N'a.FirstSeenAt ASC'
ELSE N'a.AlertId ASC' -- default; never pass @Sort through raw
END;
SET @sql = N'SELECT … FROM … ORDER BY ' + @orderBy;
EXEC sys.sp_executesql @sql; -- no user values in @orderBy
Often enough for SPIKE boards:
WHERE (@SqlInstanceId IS NULL OR i.SqlInstanceId = @SqlInstanceId)
AND (@UnackedOnly = 0 OR a.Status = N'Open')
Watch: can inhibit index use on some shapes — measure (SQL-16 / Query Store). Still prefer this over string builds until proven painful.
sp_executesql → parameterized → better cache reuse than unique ad-hoc strings OPTION (RECOMPILE) on the inner batch when measured EXECUTE AS — broken ownership chaining is common | Scenario | Pattern |
|---|---|
| Alert/inventory board filters | Static NULL-able params first |
| “Pick columns” export | Allowlisted column list → sp_executesql |
| PS passes search text | Proc param → LIKE @p inside sp_executesql or static |
| IN-list from Blazor | TVP join — not dynamic IN (...) |
| Collector pivot weirdness | Prefer staging #temp + static SQL (SQL-13) |
| Symptom | Likely cause |
|---|---|
Odd ' errors / odd rows |
Quote concatenation / injection attempt |
| Plan cache flood | Unique @sql strings with literals |
| Works as you, fails as app | Ownership chaining + dynamic SQL |
| ORDER BY ignored / error | Client sent raw column; no allowlist |
| Security finding on proc | EXEC (@sql) with user input |
sp_executesql — try x'; SELECT @@VERSION;-- IN with TVP @Sort hits default usp_*, not in Blazor #temp beat string lists QUOTENAME EXEC (@sql) with user input in warehouse procs SQL Dude — SQL-14 Dynamic SQL v1