Introduction
Knowing how to write SQL commands can sometimes be too little for the need at hand. Sometimes one needs to make queries dynamic so that they can change them on the fly and make them work the way they need them too. The way to do this is to use the system stored procedure sp_executesql, for which you can glean more information here. This way of running queries can be useful in making an application’s code more malleable in terms of its coupling with the database. However, there are also some negative points to consider and though dynamic SQL is practical it can be dangerous at times.
sp_executesql Stored Procedure
Take the following SELECT query from the AdventureWorks2012 test database:
1 2 3 4 5 6 |
SELECT TOP 100 LineTotal, SalesOrderDetailID, OrderQty FROM Sales.SalesOrderDetail WHERE UnitPrice > 500 |
This is a simple query and it is also very static and not very interesting to have embedded in one’s application code. Granted, there are other methods of making this more supple, like using an entity framework database provider, but that is outside the scope of this article.
Using sp_executesql you can turn this simple query into a dynamically built one. The following example turns the row restriction (TOP), the list of columns and the UnitPrice predicate into dynamically given entities.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
CREATE PROCEDURE sp_GetSalesOrderDetails @NUMBER_OF_ROWS INT, @COLUMN_LIST NVARCHAR(1000), @UNIT_PRICE INT AS BEGIN DECLARE @TSQL NVARCHAR(4000) = N'SELECT TOP ' + CAST(@NUMBER_OF_ROWS AS NVARCHAR) + ' ' + ISNULL(@COLUMN_LIST, N'*') + N' FROM Sales.SalesOrderDetail WHERE ' + CASE WHEN @UNIT_PRICE IS NULL THEN N'1 = 1' ELSE N'UnitPrice >= ' + CAST(@UNIT_PRICE AS NVARCHAR) END EXEC sys.sp_executesql @TSQL END GO |
As you can see this example makes a stored procedure that accepts three variables: one for the TOP clause, one for the list of columns and one for the minimum unit price. The column list and unit price are nullable. If the column list is not given the stored procedure replaces it with a * wildcard. If the unit price is not given the code replaces it with a WHERE clause that is always true (WHERE 1 = 1).
The query code is broken down into a string of hardcoded words concatenated with the given variables in a specific order. There are ISNULL and CASE WHEN clause to deal with NULL variables. Go ahead and create the stored procedure and play with the variables to see how it acts. You can run the stored procedure by using this code:
1 2 3 |
EXEC sp_GetSalesOrderDetails @NUMBER_OF_ROWS = 2000, @COLUMN_LIST = N'CarrierTrackingNumber, UnitPrice, SalesOrderID', @UNIT_PRICE = 500 |
Obviously, this type of query can be further perfected by added a check in the sys.columns/sys.tables tables to see whether the given column list exists for example.
Problems with dynamic SQL
The first major problem that needs to be tackled whenever one uses dynamic SQL commands in one’s application code is SQL injection. The fact that the application accepts NVARCHAR strings and concatenates them with actual SQL queries that are later run opens up the possibility for SQL injection. Without going into the details yet just run this query for yourself (be sure to cancel the query because, as you’ll notice, it loops infinitely):
1 2 3 4 |
EXEC sp_GetSalesOrderDetails @NUMBER_OF_ROWS = 2000, @COLUMN_LIST = '''hello, your SQL injection worked'' ; WHILE 1 = 1 SELECT ''You got owned!''; --', @UNIT_PRICE = 500 |
So, if you’re familiar with SQL injections you’ll notice that this dynamic SQL query is cut into two different ones by adding a “;” breaker and then running a TSQL query and cutting the rest of the query off by add comment markers “–“ The query that is actually being run is the following:
1 2 3 4 |
SELECT TOP 2000 'hello, your SQL injection worked' ; WHILE 1 = 1 SELECT 'You got owned!'; -- FROM Sales.SalesOrderDetail WHERE UnitPrice >= 500 |
Granted, this SQL injection is pretty bad but it could be much worse. Depending on how wise the solution architect was the query may or may not be running with limited privileges. But even if the SQL user the application is using only has read/write privileges the person doing the SQL injection attack could replace WHILE 1 = 1 SELECT‘You got owned!’; with a “DELETE FROM” SQL Command. You can try it by running this query (please backup your database if you are not ready to lose the data):
1 2 3 |
EXEC sp_GetSalesOrderDetails @NUMBER_OF_ROWS = 2000, @COLUMN_LIST = '''hello, your SQL injection worked'' ; DELETE ', @UNIT_PRICE = NULL |
This security nightmare can get a whole lot worse if an unscrupulous architect assigned a user with sysadmin privileges to the application. In that case it is not impossible to drop users, table, or worse yet, entire databases from the SQL Server instance via SQL command injection. So, please take note that one should be certain the application user has minimal security rights and try to use different data types than strings and cast them to nvarchar in the stored procedure later. This has be done in the above stored procedure for the @NUMBER_OF_ROWS and @UNIT_PRICE variables and they are therefore not at risk of SQL injection.
SQL injection is the most drastic negative point of using dynamic SQL commands but there is another weak-spot and that has to do with performance. Due to the fact that SQL Server stores optimized query plans in cache when using stored procedures one may think that using dynamic SQL in a stored procedure as above would be optimizing the system as opposed to building ad-hoc queries in the application and generating a new query plan every time they are run. However, using dynamic-SQL breaks down this capability as the original query plan becomes invalidated so the engine has to create a new one whenever a new version of the dynamic SQL command is run. More information about that here and here. You may want to look into parameterization to safely navigate those waters. Bear in mind, however, that this only applies to extreme highly transactional queries that run thousands of times per day. The caching of a new query plan is a quick process (more info about the intricacies of this process can be found here). This can be tested by used the SET STATISTICS TIME ON; command. If you run a query for the first time there is a small amount of time allocated to “parse and compile time”. This time differs between different servers with varying amounts of power. It also varies with the complexity of the query. However, you can try it yourself by running the following query to create test data:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
DECLARE @START_DATE DATETIME DECLARE @ENDDATE DATETIME SET @START_DATE = '19500101' SET @ENDDATE = '20991231' ; WITH CTE_DATES AS ( SELECT @START_DATE DateValue UNION ALL SELECT DateValue + 1 FROM CTE_DATES WHERE DateValue + 1 < @ENDDATE) SELECT CAST(DateValue AS date) AS DateTest INTO #tempTestTable FROM CTE_DATES OPTION (MAXRECURSION 0) |
Now when you run this simple query you will see that the first time you run it there is some time allocated to compilation and storing the query plan in cache:
1 2 3 4 5 6 7 8 9 |
SET STATISTICS TIME ON SELECT * FROM dbo.#tempTestTable WHERE DateTest BETWEEN '19940101' AND '20050101' SET STATISTICS TIME OFF |
You should see some time allocated to parsing and compilation as follows:
1 2 3 4 |
SQL Server parse and compile time: CPU time = 69 ms, elapsed time = 69 ms. |
However if you rerun this exact query you will notice that this time is almost non-existent:
1 2 3 4 |
SQL Server parse and compile time: CPU time = 0 ms, elapsed time = 1 ms. |
If the query test itself was changed then the engine would be forced to recompile and re-cache a new query plan. The times to do this are small but if the query is run extremely often they can add up to lots of wasted resources.
So when can one safely use dynamic SQL?
Dynamic SQL can be used by a developer to create an agile query that allows for dynamic results. However, we have seen that this practice can be very dangerous. There are, however, other uses for this type of dynamic SQL. DBAs may use it to help in maintenance/administrative tasks and indeed it works wonders for saving time. SQL injection and query plan optimization is not a problem in this case because these scripts are not run constantly and repetitively as are application/production queries.
I will leave you with a very handy index maintenance query for any DBA that does the following using a dynamic SQL command:
- Creates a temporary work table
- Inserts the index names, table names and database names of all table that needs either a REBUILD or REORGANIZE (this info is included too)
- Using cursors, the query builds a dynamic T-SQL command for each index (ALTER INDEX) and runs a rebuild on indexes over 30% fragmented and a reorganize for indexes between 10 and 30% fragmented.
- This followed by an error handing CATCH statement so the query can keep running (this generally happens with older data-types that cannot be build ONLINE for security reasons.
- Finally the query runs an index statistics update so the engine can use these fresh statistics for future query plans.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
CREATE TABLE dbo.#FragTab (DB_Name varchar(100), [Schema] varchar(50), [Table] varchar(200), [Index] varchar(200), avg_fragmentation_in_percent FLOAT, REBUILD_Necessary BIT, REORGANISE_Necessary BIT) EXEC sys.sp_MSforeachdb ' USE ?; INSERT INTO #FragTab SELECT DBs.name as ''DB_Name'', dbschemas.[name] as ''Schema'', dbtables.[name] as ''Table'', dbindexes.[name] as ''Index'', indexstats.avg_fragmentation_in_percent, CASE WHEN indexstats.avg_fragmentation_in_percent > 30 THEN 1 ELSE 0 END as REBUILD_Necessary, CASE WHEN indexstats.avg_fragmentation_in_percent BETWEEN 10 AND 30 THEN 1 ELSE 0 END as REORGANISE_Necessary FROM sys.dm_db_index_physical_stats (DB_ID(), NULL, NULL, NULL, NULL) AS indexstats INNER JOIN sys.tables dbtables on dbtables.[object_id] = indexstats.[object_id] INNER JOIN sys.schemas dbschemas on dbtables.[schema_id] = dbschemas.[schema_id] INNER JOIN sys.indexes AS dbindexes ON dbindexes.[object_id] = indexstats.[object_id] AND indexstats.index_id = dbindexes.index_id LEFT OUTER JOIN sys.databases as DBs ON DBs.database_id = indexstats.database_id WHERE indexstats.database_id = DB_ID() AND indexstats.avg_fragmentation_in_percent > 10 AND dbindexes.name IS NOT NULL AND indexstats.database_id > 4' -- Exclude the system databases (msdb, tempdb, master) DECLARE @table VARCHAR(200) DECLARE @Index VARCHAR(200) DECLARE @DB_Name VARCHAR(100) DECLARE @SQL NVARCHAR(4000) DECLARE CUR_INDEXES_REBUILD CURSOR FOR SELECT [Table], [Index], [DB_Name] FROM #FragTab WHERE REBUILD_Necessary = 1 OPEN CUR_INDEXES_REBUILD FETCH NEXT FROM CUR_INDEXES_REBUILD INTO @table, @Index, @DB_name WHILE @@FETCH_STATUS = 0 BEGIN BEGIN TRY SET @SQL = 'ALTER INDEX [' + @Index + '] ON [' + @DB_Name + '].[dbo].[' + @table + '] REBUILD WITH (ONLINE = ON) ' EXEC sys.sp_executesql @SQL END TRY BEGIN CATCH PRINT 'An error occured on the table : ' + @Table +' in the database :' + @DB_name + ', the cursor will continue treating other indexes. The error message is : ' + ERROR_MESSAGE() END CATCH FETCH NEXT FROM CUR_INDEXES_REBUILD INTO @table, @Index, @DB_name END CLOSE CUR_INDEXES_REBUILD DEALLOCATE CUR_INDEXES_REBUILD DECLARE CUR_INDEXES_REORGANIZE CURSOR FOR SELECT [Table], [Index], [DB_Name] FROM #FragTab WHERE REORGANISE_Necessary = 1 OPEN CUR_INDEXES_REORGANIZE FETCH NEXT FROM CUR_INDEXES_REORGANIZE INTO @table, @Index, @DB_name WHILE @@FETCH_STATUS = 0 BEGIN BEGIN TRY SET @SQL = 'ALTER INDEX [' + @Index + '] ON [' + @DB_Name + '].[dbo].[' + @table + '] REORGANIZE ' EXEC sys.sp_executesql @SQL END TRY BEGIN CATCH PRINT 'An error occured on the table : ' + @Table +' in the database :' + @DB_name + ', the cursor will continue treating other indexes. The error message is : ' + ERROR_MESSAGE() END CATCH FETCH NEXT FROM CUR_INDEXES_REORGANIZE INTO @table, @Index, @DB_name END CLOSE CUR_INDEXES_REORGANIZE DEALLOCATE CUR_INDEXES_REORGANIZE DROP TABLE #FragTab EXEC sys.sp_MSforeachdb ' USE ?; EXEC sp_updatestats; ' |
Conclusion
Dynamic T-SQL commands are very handy and are fun to use. They can make life easier for developers but lots of thought should go into the subject and this should be discussed with a DBA beforehand. In most cases, it may be better to take a parameterized SQL command approach. However, for maintenance or investigative reasons dynamic SQL commands can be a wonderful tool for DBAs. The article includes a use case for rebuilding and reorganizing indexes but one could also use it for restoring multiple databases.
- SQL Server Commands – Dynamic SQL - July 4, 2014
- SQL Server cursor performance problems - June 18, 2014
- SQL Server cursor tutorial - June 4, 2014