Day 28: Dynamic SQL & metaprogramming
On this page
- A. Lesson overview
- B. Theory explained clearly
- C. Dataset and starting state
- D. Worked examples
- E. Guided practical lab — A safe dynamic search with a sortable result
- F. Independent practice
- G. Solutions and explanations
- H. Knowledge check
- I. Troubleshooting and cleanup
- J. Completion checklist, recap and references
By the end of this lesson you can
- Decide when dynamic SQL is justified, and when static SQL,
OPTION (RECOMPILE)or a different design is better. - Execute dynamic SQL with
sp_executesql, passing input and output parameters. - Demonstrate SQL injection in a safe training setting and prevent it with parameters.
- Handle identifiers safely: validate against the catalog or a whitelist, then quote with
QUOTENAME. - Generate SQL from metadata (metaprogramming) with
STRING_AGG. - Explain how dynamic SQL interacts with plan caching and with ownership chaining, and apply least privilege.
A. Lesson overview
| Week | 4 — Performance and expert topics |
| Level | Advanced |
| Estimated time | 2 h 45 min essential + 30 min optional stretch |
| Prerequisites | Day 11 (dynamic SQL primer, dynamic PIVOT), Day 17 (procedures), Day 20 (error handling), Day 24 (plan caching, catch-all queries), Day 26 (permissions) |
| Syllabus topic | Dynamic SQL & Metaprogramming — sp_executesql, parameterisation, identifier validation, QUOTENAME, injection prevention |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — When dynamic SQL is (and isn’t) the answer | 10 min |
Part 2 — EXEC() vs sp_executesql |
15 min |
| Part 3 — SQL injection and how to prevent it | 20 min |
Part 4 — Identifiers: QUOTENAME and whitelists |
15 min |
| Part 5 — Practicalities: scope, strings, debugging, permissions | 15 min |
| Worked examples | 30 min |
| Guided lab (a safe dynamic search) | 30 min |
| Independent practice | 25 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. Users want a search screen with any combination of filters and a choice of sort column; administrators want reports that run against “every table in a schema”. Both need SQL text built at run time — and the security team needs proof that no input can change what that SQL does.
Safety. Today includes deliberate SQL injection against a training procedure. Run it only in
RetailLab. One task drops a table created for that purpose (Lab.InjectionCanary) — it’s labelled where it happens.
B. Theory explained clearly
Part 1 — When is dynamic SQL justified?
Dynamic SQL builds a statement as a string and executes it. Use it when the shape of the query must change at run time:
| Need | Dynamic SQL? |
|---|---|
| A table, column, schema or sort column chosen at run time | Yes — identifiers can’t be parameters |
| Optional filters (search screens) | Optional — OPTION (RECOMPILE) (Day 24) is simpler; dynamic SQL caches a plan per filter combination |
Column lists that depend on data (dynamic PIVOT) |
Yes |
| Running the same operation across many objects (admin, metadata) | Yes |
| Different values at run time | No — use parameters in static SQL |
Dynamic SQL is harder to read, test and secure, and the compiler can’t check it until it runs. Prefer static SQL whenever it does the job.
Part 2 — EXEC() vs sp_executesql
-- EXEC(): executes a string. Values must be concatenated in → injection risk, poor plan reuse
DECLARE @sql nvarchar(max) = N'SELECT COUNT(*) FROM Sales.SalesOrder WHERE OrderStatus = ''Pending''';
EXEC (@sql);
-- sp_executesql: the statement has typed parameters, supplied separately
DECLARE @n int;
EXEC sys.sp_executesql
N'SELECT @Count = COUNT(*) FROM Sales.SalesOrder WHERE OrderStatus = @Status;', -- statement
N'@Status varchar(10), @Count int OUTPUT', -- parameter definitions
@Status = 'Pending', @Count = @n OUTPUT; -- values
SELECT @n AS PendingOrders; -- 4
EXEC (@sql) |
sp_executesql |
|
|---|---|---|
| Values | Concatenated into the text | Passed as typed parameters |
| Injection through values | Possible | Not possible — values are never parsed as SQL |
| Plan reuse | Only if the text is identical, values included | One plan per statement text, reused for any values (and subject to parameter sniffing, Day 24) |
| Output values | Not directly | OUTPUT parameters |
Rule: values always go in as parameters. Only identifiers and keywords — which can’t be parameters — are ever concatenated, and only after validation.
Part 3 — SQL injection
If user input is concatenated into SQL, the input can become SQL:
SET @sql = N'SELECT … FROM Sales.Customer WHERE LastName = ''' + @LastName + N'''';
-- @LastName = N'Carter' → … WHERE LastName = 'Carter' (as intended)
-- @LastName = N''' OR 1=1 --' → … WHERE LastName = '' OR 1=1 --' (every row)
-- @LastName = N'''; DROP TABLE …' → a second statement runs with the caller's rights
Escaping quotes by hand (REPLACE(@x, '''', '''''')) is fragile — easy to forget, wrong for identifiers and numbers, and defeated by truncation tricks. Defences, in order:
- Parameters for every value (
sp_executesql). - Whitelists for identifiers and keywords (sort columns,
ASC/DESC): accept only known values. QUOTENAMEaround every identifier, after validation.- Least privilege: the dynamic statement runs with as few rights as possible.
Part 4 — Identifiers: validate, then quote
QUOTENAME(N'Sales') returns [Sales]; it doubles any ] inside, so the value can’t break out of the brackets. It returns NULL for input longer than 128 characters — and NULL concatenated into a string makes the whole string NULL, so check for that.
Validate identifiers against the catalog:
IF NOT EXISTS (SELECT 1 FROM sys.tables AS t
WHERE t.name = @TableName AND SCHEMA_NAME(t.schema_id) = @SchemaName)
THROW 50600, N'Unknown table.', 1;
SET @sql = N'SELECT COUNT_BIG(*) FROM ' + QUOTENAME(@SchemaName) + N'.' + QUOTENAME(@TableName) + N';';
Use the sysname type (nvarchar(128)) for identifier variables. For a sort column, a fixed list (IN ('OrderDate', 'OrderID', 'CustomerID')) is stronger than “any column of the table”.
Part 5 — Practicalities
| Topic | What to know |
|---|---|
| Scope | Dynamic SQL runs in its own batch: it can’t see your local variables (pass them as parameters) but can see #temp tables created before it. Temp tables it creates vanish when it ends |
| String types | Build in nvarchar(max). Concatenating only non-max strings can silently truncate at 4,000 characters — start with an nvarchar(max) variable or CAST(N'' AS nvarchar(max)) |
| Debugging | Add a @Debug bit parameter that PRINTs (first 4,000 characters) or SELECTs the statement instead of running it |
| Plan cache | Each distinct statement text gets its own cached plan — fine for a handful of filter combinations, a plan-cache flood if values are concatenated |
| Permissions | Dynamic SQL breaks ownership chaining (Day 21): the caller needs permissions on the objects the dynamic statement uses. Options: grant those permissions, EXECUTE AS on the procedure (with care), or certificate signing (the most precise) |
| Readability | Use CHAR(13) + CHAR(10) line breaks and indentation in generated code; FORMATMESSAGE/REPLACE with placeholder tokens can make templates clearer |
Metaprogramming means generating code from metadata (sys.tables, sys.columns…), typically with STRING_AGG: row counts for every table, data profiles for every column, maintenance statements.
C. Dataset and starting state
| Dataset | Shared synthetic dataset |
| Database | RetailLab |
| Read from | Sales.*, Inventory.*, HR.*, Staging.*, catalog views |
| Changed | Lab.InjectionCanary (created and — deliberately — dropped in task 2) |
| Permissions | CREATE PROCEDURE, CREATE TABLE, ALTER on schema Lab; task 4 needs CREATE USER |
| Objects created | Lab.usp_FindCustomer_Unsafe, Lab.usp_FindCustomer, Lab.usp_CountRows, Lab.usp_SearchOrdersDyn, Lab.usp_ListProducts_Unsafe, Lab.usp_ListProducts, Lab.usp_StoreYearPivot; test user SearchUser |
Setup. No script; today’s objects are created as you go. Make sure the core data is intact.
Validation. Expected: 40, 376, 30.
USE RetailLab;
GO
SELECT (SELECT COUNT(*) FROM Sales.Customer) AS Customers,
(SELECT COUNT(*) FROM Sales.SalesOrder) AS Orders,
(SELECT COUNT(*) FROM Inventory.Product) AS Products;
D. Worked examples
Worked example 1 — Injection, then the fix
-- DELIBERATELY VULNERABLE — training only
CREATE OR ALTER PROCEDURE Lab.usp_FindCustomer_Unsafe
@LastName nvarchar(50)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @sql nvarchar(max) =
N'SELECT CustomerID, FirstName, LastName FROM Sales.Customer WHERE LastName = N''' + @LastName + N''';';
EXEC (@sql);
END;
GO
EXEC Lab.usp_FindCustomer_Unsafe @LastName = N'Carter';
EXEC Lab.usp_FindCustomer_Unsafe @LastName = N''' OR 1=1 --';
GO
-- Safe: the value is a parameter
CREATE OR ALTER PROCEDURE Lab.usp_FindCustomer
@LastName nvarchar(50)
AS
BEGIN
SET NOCOUNT ON;
EXEC sys.sp_executesql
N'SELECT CustomerID, FirstName, LastName FROM Sales.Customer WHERE LastName = @LastName;',
N'@LastName nvarchar(50)',
@LastName = @LastName;
END;
GO
EXEC Lab.usp_FindCustomer @LastName = N'Carter';
EXEC Lab.usp_FindCustomer @LastName = N''' OR 1=1 --';
Expected result: unsafe: 1 row (2 Ben Carter), then 40 rows — the input rewrote the WHERE clause to LastName = N'' OR 1=1 --'. Safe: 1 row, then 0 rows — SQL Server looked for a customer whose surname is literally ' OR 1=1 --. (Of course, this search needs no dynamic SQL at all; the point is the mechanism.)
Worked example 2 — Output parameters
DECLARE @Status varchar(10), @n int;
DECLARE @Results TABLE (OrderStatus varchar(10), Orders int);
DECLARE @Statuses TABLE (s varchar(10));
INSERT INTO @Statuses VALUES ('Cancelled'), ('Delivered'), ('Pending'), ('Shipped');
DECLARE c CURSOR LOCAL FAST_FORWARD FOR SELECT s FROM @Statuses ORDER BY s;
OPEN c;
FETCH NEXT FROM c INTO @Status;
WHILE @@FETCH_STATUS = 0
BEGIN
EXEC sys.sp_executesql
N'SELECT @Count = COUNT(*) FROM Sales.SalesOrder WHERE OrderStatus = @Status;',
N'@Status varchar(10), @Count int OUTPUT',
@Status = @Status, @Count = @n OUTPUT;
INSERT INTO @Results VALUES (@Status, @n);
FETCH NEXT FROM c INTO @Status;
END;
CLOSE c; DEALLOCATE c;
SELECT * FROM @Results ORDER BY OrderStatus;
Expected result: Cancelled 7, Delivered 357, Pending 4, Shipped 8. The same statement text ran four times with different values: one cached plan. (A GROUP BY would do this in one static query, of course — the loop is here to show output parameters; cursors are otherwise avoided in this course.)
Worked example 3 — A validated identifier
CREATE OR ALTER PROCEDURE Lab.usp_CountRows
@SchemaName sysname,
@TableName sysname,
@RowCount bigint OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @sql nvarchar(max);
IF NOT EXISTS (SELECT 1 FROM sys.tables AS t
WHERE t.name = @TableName AND t.schema_id = SCHEMA_ID(@SchemaName))
THROW 50600, N'Unknown schema or table.', 1;
SET @sql = N'SELECT @n = COUNT_BIG(*) FROM ' + QUOTENAME(@SchemaName) + N'.' + QUOTENAME(@TableName) + N';';
EXEC sys.sp_executesql @sql, N'@n bigint OUTPUT', @n = @RowCount OUTPUT;
END;
GO
DECLARE @rc bigint;
EXEC Lab.usp_CountRows N'Sales', N'SalesOrder', @rc OUTPUT;
SELECT @rc AS SalesOrderRows;
GO
DECLARE @rc bigint;
EXEC Lab.usp_CountRows N'Sales', N'SalesOrder; DROP TABLE Lab.Anything', @rc OUTPUT;
GO
SELECT QUOTENAME(N'odd]name') AS Quoted;
Expected result: 376; then Msg 50600 … Unknown schema or table.; then [odd]]name] — the ] is doubled so it can’t close the bracket early. Validation rejects anything that isn’t a real table; QUOTENAME is the second layer.
Worked example 4 — Generating SQL from metadata
DECLARE @sql nvarchar(max);
SELECT @sql = STRING_AGG(
CAST(N'SELECT ' + QUOTENAME(s.name + N'.' + t.name, '''') + N' AS TableName, COUNT_BIG(*) AS RowCnt FROM '
+ QUOTENAME(s.name) + N'.' + QUOTENAME(t.name) AS nvarchar(max)),
NCHAR(13) + NCHAR(10) + N'UNION ALL ')
WITHIN GROUP (ORDER BY s.name, t.name)
FROM sys.tables AS t
JOIN sys.schemas AS s ON s.schema_id = t.schema_id
WHERE s.name IN (N'HR', N'Inventory', N'Sales', N'Staging');
SET @sql += N' ORDER BY TableName;';
PRINT @sql; -- inspect what will run
EXEC sys.sp_executesql @sql;
Expected result: PRINT shows 13 SELECT … UNION ALL lines; the result has 13 rows: HR.Department 6, HR.Employee 16, Inventory.Category 15, Inventory.Product 30, Inventory.Stock 49, Inventory.Warehouse 2, Sales.Customer 40, Sales.Payment 395, Sales.SalesOrder 376, Sales.SalesOrderLine 702, Sales.Store 3, Staging.CustomerImport 15, Staging.ProductPriceUpdate 10 — the same numbers the setup validation script checks. QUOTENAME(…, '''') quotes a string literal safely; CAST(… AS nvarchar(max)) stops STRING_AGG from failing at 8,000 bytes.
E. Guided practical lab — A safe dynamic search with a sortable result
Build the dynamic alternative to Day 24’s catch-all search: only the filters supplied appear in the SQL; the sort column comes from a whitelist.
Step 1 — Create the procedure
- Do: Build the statement piece by piece.
- Run:
USE RetailLab; GO CREATE OR ALTER PROCEDURE Lab.usp_SearchOrdersDyn @CustomerID int = NULL, @FromDate date = NULL, @ToDate date = NULL, -- exclusive @Status varchar(10) = NULL, @SortColumn sysname = N'OrderDate', @SortDesc bit = 0, @Debug bit = 0 AS BEGIN SET NOCOUNT ON; IF @SortColumn NOT IN (N'OrderID', N'OrderDate', N'CustomerID', N'OrderStatus') THROW 50600, N'Sort column must be OrderID, OrderDate, CustomerID or OrderStatus.', 1; DECLARE @sql nvarchar(max) = N'SELECT OrderID, CustomerID, OrderDate, OrderStatus' + NCHAR(10) + N'FROM Sales.SalesOrder' + NCHAR(10) + N'WHERE 1 = 1'; IF @CustomerID IS NOT NULL SET @sql += NCHAR(10) + N' AND CustomerID = @CustomerID'; IF @FromDate IS NOT NULL SET @sql += NCHAR(10) + N' AND OrderDate >= @FromDate'; IF @ToDate IS NOT NULL SET @sql += NCHAR(10) + N' AND OrderDate < @ToDate'; IF @Status IS NOT NULL SET @sql += NCHAR(10) + N' AND OrderStatus = @Status'; SET @sql += NCHAR(10) + N'ORDER BY ' + QUOTENAME(@SortColumn) + CASE WHEN @SortDesc = 1 THEN N' DESC' ELSE N' ASC' END + N', OrderID;'; IF @Debug = 1 BEGIN PRINT @sql; RETURN; END; EXEC sys.sp_executesql @sql, N'@CustomerID int, @FromDate date, @ToDate date, @Status varchar(10)', @CustomerID = @CustomerID, @FromDate = @FromDate, @ToDate = @ToDate, @Status = @Status; END; GO - Observe: Commands completed successfully.
- Why it matters: Every value is a parameter — even those not used in the text (passing unused parameters is allowed). The only concatenated parts are fixed fragments, a whitelisted, quoted column name, and
ASC/DESCchosen by abit. - Confirm:
SELECT OBJECT_ID(N'Lab.usp_SearchOrdersDyn');is not NULL.
Step 2 — Look before you run
- Do: Use the debug switch.
- Run:
EXEC Lab.usp_SearchOrdersDyn @FromDate = '2025-12-01', @ToDate = '2026-01-01', @Status = 'Pending', @Debug = 1; - Observe: the Messages tab shows:
SELECT OrderID, CustomerID, OrderDate, OrderStatus FROM Sales.SalesOrder WHERE 1 = 1 AND OrderDate >= @FromDate AND OrderDate < @ToDate AND OrderStatus = @Status ORDER BY [OrderDate] ASC, OrderID; - Why it matters: Being able to see the generated statement is essential for debugging and for review.
- Confirm: no literal values appear in the text.
Step 3 — Run the searches
- Do: Try several combinations.
- Run:
EXEC Lab.usp_SearchOrdersDyn @CustomerID = 18, @SortDesc = 1; EXEC Lab.usp_SearchOrdersDyn @FromDate = '2025-12-01', @ToDate = '2026-01-01'; EXEC Lab.usp_SearchOrdersDyn @FromDate = '2025-12-01', @ToDate = '2026-01-01', @Status = 'Pending'; EXEC Lab.usp_SearchOrdersDyn; - Observe: 27 rows (first: order 1351, 2025-12-05); 27 rows; 4 rows; 376 rows.
- Why it matters: Each combination produces a different, simple statement that the optimiser can plan well.
- Confirm: the row counts.
Step 4 — Attack it
- Do: Try to inject through the sort column and the status.
- Run:
EXEC Lab.usp_SearchOrdersDyn @SortColumn = N'OrderDate; DROP TABLE Lab.Anything; --'; GO EXEC Lab.usp_SearchOrdersDyn @Status = 'x'' OR 1=1 --'; - Observe: Msg 50600 … Sort column must be …; then 0 rows (and in fact
@Statusisvarchar(10), so the value is also truncated tox' OR 1=1, which matches nothing). - Why it matters: The whitelist stops identifier injection; parameters stop value injection.
- Confirm: both attacks failed harmlessly.
Step 5 — One plan per shape
- Do: Look at the plan cache for the generated statements.
- Run:
SELECT cp.usecounts, LEFT(st.text, 120) AS StatementStart FROM sys.dm_exec_cached_plans AS cp CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) AS st WHERE st.text LIKE N'%FROM Sales.SalesOrder%WHERE 1 = 1%' AND st.text NOT LIKE N'%dm_exec_cached_plans%' ORDER BY StatementStart; - Observe: one entry per distinct statement shape you ran (four in step 3, possibly more if you experimented);
usecountsrises when the same shape is reused with new values. - Why it matters: This is why values must be parameters: concatenated values would create a new cached plan for every search.
- Confirm: each entry’s text contains
@parameters, not values. (NeedsVIEW SERVER STATEorVIEW SERVER PERFORMANCE STATE; the owner of a local instance normally has it.)
F. Independent practice
Task 1 — A dynamic pivot
Write Lab.usp_StoreYearPivot that returns one row per store and one column per order year found in Sales.SalesOrder (store orders only), with order counts. Build the column list from the data with STRING_AGG and QUOTENAME.
Success criteria: columns StoreName, [2024], [2025]; rows: Edinburgh Old Town 10 / 29, London Covent Garden 30 / 21, Manchester Northern Quarter 30 / 38. When 2026 orders appear, a [2026] column appears without code changes.
Task 2 — Find and fix an injectable sort (contains a deliberate destructive step in the training database)
Create Lab.InjectionCanary (ID int) with one row, and this vulnerable procedure:
CREATE OR ALTER PROCEDURE Lab.usp_ListProducts_Unsafe @OrderBy nvarchar(200)
AS
BEGIN
DECLARE @sql nvarchar(max) = N'SELECT ProductCode, ProductName, ListPrice FROM Inventory.Product ORDER BY ' + @OrderBy + N';';
EXEC (@sql);
END;
Call it with N'ListPrice', then with N'ListPrice; DROP TABLE Lab.InjectionCanary; --'. Confirm the canary is gone. Then write Lab.usp_ListProducts @SortColumn sysname, @SortDesc bit = 0 that is not injectable, and show the attack fails.
Success criteria: the attack drops Lab.InjectionCanary (OBJECT_ID returns NULL). The safe version returns 30 rows for ProductName and raises error 50601 for the attack string.
Task 3 — Profile every text column
Generate one query that returns, for every char/varchar/nchar/nvarchar column of Sales.Customer: the column name, number of NULLs, and number of distinct non-NULL values.
Success criteria: 7 rows. NULLs: Email 4, Phone 10, all others 0. Distinct values: CustomerSegment 3; Email 36.
Task 4 — Dynamic SQL and permissions (challenging)
Create user SearchUser without login and grant only EXECUTE on Lab.usp_SearchOrdersDyn. As SearchUser, run a search. Explain the error. Then fix it with the narrowest change you can justify, and test again.
Success criteria: first attempt: Msg 229 … SELECT permission was denied on the object ‘SalesOrder’… — ownership chaining doesn’t extend into dynamic SQL. After the fix (e.g. GRANT SELECT ON Sales.SalesOrder TO SearchUser, or WITH EXECUTE AS OWNER on the procedure), the search returns 27 rows for customer 18.
Stretch challenge — Metadata-driven reconciliation
Using generated SQL, compare COUNT_BIG(*) with the row count in sys.dm_db_partition_stats for every table in Sales and Inventory, returning schema, table, both counts and whether they match.
Success criteria: 9 rows (5 Sales + 4 Inventory tables), all matching. Explain why the DMV count is instant and the COUNT_BIG(*) count isn’t, and when they might differ.
G. Solutions and explanations
Show solution 1
CREATE OR ALTER PROCEDURE Lab.usp_StoreYearPivot
AS
BEGIN
SET NOCOUNT ON;
DECLARE @cols nvarchar(max), @sql nvarchar(max);
SELECT @cols = STRING_AGG(QUOTENAME(CAST(y AS nvarchar(4))), N', ') WITHIN GROUP (ORDER BY y)
FROM (SELECT DISTINCT YEAR(OrderDate) AS y FROM Sales.SalesOrder WHERE StoreID IS NOT NULL) AS d;
SET @sql = N'
SELECT StoreName, ' + @cols + N'
FROM (SELECT s.StoreName, YEAR(o.OrderDate) AS OrderYear, o.OrderID
FROM Sales.SalesOrder AS o
JOIN Sales.Store AS s ON s.StoreID = o.StoreID) AS src
PIVOT (COUNT(OrderID) FOR OrderYear IN (' + @cols + N')) AS p
ORDER BY StoreName;';
EXEC sys.sp_executesql @sql;
END;
GO
EXEC Lab.usp_StoreYearPivot;
Reasoning. The years come from the data, but they are still quoted with QUOTENAME — a habit that costs nothing and protects you if the source of the list changes. This is the Day 11 pattern, now wrapped in a procedure.
Show solution 2
-- DESTRUCTIVE TEST (training database only): the attack drops this table
DROP TABLE IF EXISTS Lab.InjectionCanary;
CREATE TABLE Lab.InjectionCanary (ID int);
INSERT INTO Lab.InjectionCanary VALUES (1);
GO
-- (create Lab.usp_ListProducts_Unsafe as given in the task)
GO
EXEC Lab.usp_ListProducts_Unsafe N'ListPrice';
EXEC Lab.usp_ListProducts_Unsafe N'ListPrice; DROP TABLE Lab.InjectionCanary; --';
SELECT OBJECT_ID(N'Lab.InjectionCanary') AS CanaryObjectID; -- NULL: the table is gone
GO
CREATE OR ALTER PROCEDURE Lab.usp_ListProducts
@SortColumn sysname,
@SortDesc bit = 0
AS
BEGIN
SET NOCOUNT ON;
IF @SortColumn NOT IN (N'ProductCode', N'ProductName', N'ListPrice', N'CategoryID')
THROW 50601, N'Invalid sort column.', 1;
DECLARE @sql nvarchar(max) =
N'SELECT ProductCode, ProductName, ListPrice FROM Inventory.Product ORDER BY '
+ QUOTENAME(@SortColumn) + CASE WHEN @SortDesc = 1 THEN N' DESC' ELSE N' ASC' END
+ N', ProductCode;';
EXEC sys.sp_executesql @sql;
END;
GO
EXEC Lab.usp_ListProducts N'ProductName';
EXEC Lab.usp_ListProducts N'ListPrice; DROP TABLE Lab.InjectionCanary; --'; -- 50601
Reasoning. The unsafe procedure turned a sort option into a second statement, executed with the caller’s rights — DROP TABLE worked because you are the database owner. A low-privileged caller would have been stopped by permissions, which is why least privilege is a defence layer, not the defence. The tie-breaker , ProductCode makes the order deterministic. This particular case doesn’t even need dynamic SQL: ORDER BY CASE @SortColumn WHEN N'ListPrice' THEN … END works statically (with care over data types).
Show solution 3
DECLARE @sql nvarchar(max);
SELECT @sql = STRING_AGG(CAST(
N'SELECT ' + QUOTENAME(c.name, '''') + N' AS ColumnName, '
+ N'SUM(CASE WHEN ' + QUOTENAME(c.name) + N' IS NULL THEN 1 ELSE 0 END) AS NullCount, '
+ N'COUNT(DISTINCT ' + QUOTENAME(c.name) + N') AS DistinctValues FROM Sales.Customer'
AS nvarchar(max)), N' UNION ALL ') WITHIN GROUP (ORDER BY c.column_id)
FROM sys.columns AS c
JOIN sys.types AS ty ON ty.user_type_id = c.user_type_id
WHERE c.object_id = OBJECT_ID(N'Sales.Customer')
AND ty.name IN (N'char', N'varchar', N'nchar', N'nvarchar');
EXEC sys.sp_executesql @sql;
Result: FirstName 0 …, LastName 0 …, Email 4 36, Phone 10 …, City 0 …, Country 0 …, CustomerSegment 0 3.
Reasoning. Column names come from sys.columns, so they are real — and they’re still quoted. QUOTENAME(name, '''') produces a safely quoted string literal for the label. COUNT(DISTINCT col) ignores NULLs. The same generator works for any table by changing one OBJECT_ID — that’s metaprogramming.
Show solution 4
CREATE USER SearchUser WITHOUT LOGIN;
GRANT EXECUTE ON Lab.usp_SearchOrdersDyn TO SearchUser;
GO
EXECUTE AS USER = 'SearchUser';
EXEC Lab.usp_SearchOrdersDyn @CustomerID = 18; -- Msg 229
REVERT;
GO
-- Option A: grant read on exactly the table the dynamic SQL reads
GRANT SELECT ON Sales.SalesOrder TO SearchUser;
GO
EXECUTE AS USER = 'SearchUser';
EXEC Lab.usp_SearchOrdersDyn @CustomerID = 18; -- 27 rows
REVERT;
GO
REVOKE SELECT ON Sales.SalesOrder FROM SearchUser;
DROP USER SearchUser;
Reasoning. With static SQL, ownership chaining means EXECUTE is enough. Dynamic SQL is a separate batch, so permissions are checked against the caller. Option A is simple but gives the user direct SELECT on the table. Option B — CREATE OR ALTER PROCEDURE … WITH EXECUTE AS OWNER — runs the whole procedure as its owner, which also changes USER_NAME() and auditing inside it; acceptable here because the procedure validates all input. The most precise option is certificate signing: sign the procedure with a certificate whose user has only SELECT on Sales.SalesOrder — beyond today’s scope, but worth knowing for production.
Show the stretch solution
DECLARE @sql nvarchar(max);
SELECT @sql = STRING_AGG(CAST(
N'SELECT ' + QUOTENAME(s.name, '''') + N' AS SchemaName, ' + QUOTENAME(t.name, '''') + N' AS TableName, '
+ N'(SELECT COUNT_BIG(*) FROM ' + QUOTENAME(s.name) + N'.' + QUOTENAME(t.name) + N') AS CountedRows, '
+ N'(SELECT SUM(ps.row_count) FROM sys.dm_db_partition_stats AS ps WHERE ps.object_id = '
+ CAST(t.object_id AS nvarchar(20)) + N' AND ps.index_id IN (0, 1)) AS MetadataRows'
AS nvarchar(max)), N' UNION ALL ') WITHIN GROUP (ORDER BY s.name, t.name)
FROM sys.tables AS t
JOIN sys.schemas AS s ON s.schema_id = t.schema_id
WHERE s.name IN (N'Sales', N'Inventory');
SET @sql = N'SELECT *, CASE WHEN CountedRows = MetadataRows THEN ''Match'' ELSE ''Differs'' END AS Result FROM ('
+ @sql + N') AS x ORDER BY SchemaName, TableName;';
EXEC sys.sp_executesql @sql;
Reasoning. sys.dm_db_partition_stats.row_count is metadata maintained by the engine — instant, but not transactionally exact while other sessions are modifying the table (and it counts rows in uncommitted transactions). COUNT_BIG(*) reads the data and respects isolation. For monitoring, metadata counts are fine; for reconciliation, count. index_id IN (0, 1) counts the heap or clustered index only, so nonclustered indexes don’t double the total.
H. Knowledge check
-
Understanding. Why can’t a table name be passed as a parameter to
sp_executesql, and what do you do instead? -
Debugging. A procedure builds
@sqlby concatenating several longN'…'literals and variables declared asnvarchar(4000); the generated statement is cut off mid-word. Why? -
Understanding. A colleague escapes values with
REPLACE(@x, '''', '''''')and concatenates them. Give two reasons to switch tosp_executesqlparameters. -
Query interpretation. A static procedure works for a user with only
EXECUTE; the dynamic version fails with SELECT permission denied. Why? -
Understanding. When is
OPTION (RECOMPILE)a better choice than dynamic SQL for a search with optional filters, and when is dynamic SQL better?
Show the answers
-
Parameters can only stand for values; table and column names are part of the statement’s structure, which must be known when it is compiled. Validate the name against the catalog (or a whitelist), quote it with
QUOTENAME, and concatenate it. -
String concatenation without any
nvarchar(max)operand produces at most 4,000nvarcharcharacters, and the variables themselves are limited to 4,000. Declare@sqlasnvarchar(max)and make sure the first operand of each concatenation ismax(e.g.CAST(N'' AS nvarchar(max)) + …). -
Parameters can’t be interpreted as SQL at all (escaping is easy to get wrong or forget, and doesn’t cover numbers, identifiers or truncation tricks); parameterised statements are reused from the plan cache instead of compiling a new plan for every value; types are enforced.
-
Dynamic SQL runs as a separate batch, so ownership chaining doesn’t apply; SQL Server checks the caller’s permissions on the tables used inside it.
-
RECOMPILEis simpler, fully static (checked at create time) and fine for searches that run occasionally; it compiles every time. Dynamic SQL caches a plan per filter combination — better for frequent searches, and needed when identifiers (sort columns, tables) vary — at the cost of more complex, carefully validated code.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Must declare the scalar variable “@x”. (from inside dynamic SQL) | Local variable referenced in the dynamic batch | Pass it as a parameter |
| Procedure expects parameter ‘@params’ of type ‘ntext/nchar/nvarchar’. | sp_executesql given a varchar statement or parameter list |
Use N'…' literals / nvarchar variables |
Whole @sql is NULL |
A NULL piece (e.g. QUOTENAME of a NULL or > 128-character name) was concatenated |
Validate inputs; use CONCAT for optional pieces |
PRINT shows only part of the statement |
PRINT truncates at 4,000 nvarchar characters |
SELECT @sql (and use Results to Text or the XML trick SELECT @sql FOR XML PATH('')) |
STRING_AGG error: aggregation result exceeded the limit of 8000 bytes |
Inputs aren’t max |
CAST(… AS nvarchar(max)) inside STRING_AGG |
| Plan cache full of near-identical statements | Values concatenated instead of parameterised | Parameterise |
Cleanup. Remove today’s procedures and test objects:
DROP PROCEDURE IF EXISTS Lab.usp_FindCustomer_Unsafe, Lab.usp_FindCustomer, Lab.usp_CountRows,
Lab.usp_SearchOrdersDyn, Lab.usp_ListProducts_Unsafe, Lab.usp_ListProducts,
Lab.usp_StoreYearPivot;
DROP TABLE IF EXISTS Lab.InjectionCanary;
DROP USER IF EXISTS SearchUser;
Core data is unchanged.
J. Completion checklist, recap and references
Completion checklist
- The unsafe search returned 40 rows for
' OR 1=1 --; the safe one returned 0. - Status counts via output parameters: 7 / 357 / 4 / 8.
- The metadata-generated query returned 13 tables with the setup-validation counts.
- The dynamic search returned 27, 27, 4 and 376 rows and resisted both attacks.
- Tasks 1–4 match the success criteria.
Recap
- Use dynamic SQL only when the query’s shape must vary.
- Values →
sp_executesqlparameters, always. Identifiers → validate (catalog or whitelist), thenQUOTENAME. - Build in
nvarchar(max); add a debug switch; inspect generated code. - One cached plan per statement shape; concatenated values flood the cache.
- Dynamic SQL breaks ownership chaining: grant narrowly,
EXECUTE AScarefully, or sign modules. - Metaprogramming with
STRING_AGGover catalog views automates repetitive SQL.
Next: Day 29 — Real-world scenarios part 1: gaps and islands, running balances and other business questions that combine everything so far.