- “Do I always need to use a BEGIN…END statement block within an IF statement?”
- “When should I include an ELSE clause within an IF statement?”
- How do I nest IF and IF…ELSE statements, if it’s even possible?”
- “Is it possible to terminate a procedures execution from within an IF statement?”
- “Which is better to use, a CASE expression or an IF (or IF…ELSE) statement?”
- “Can I use control-of-flow language to do an upsert in SQL Server?”
- “I don’t get how a WHILE loop works. Can you help make sense of what’s going on here?”
- “How do I add a pause or wait time to my T-SQL in order to delay execution?”
- “Is it possible to skip from one section of your T-SQL code to another?”
- “What’s the simplest way to incorporate error handling into my stored procedures?”
“Do I always need to use a BEGIN...END
statement block within an IF statement?”
You need to include the BEGIN...END
block only if you want to associate more than one T-SQL statement with the IF
condition. The condition is a Boolean expression within the IF
clause that directs statement execution control flow. The query engine evaluates the condition and, if it evaluates to TRUE
, runs the statement immediately following the IF
clause. Otherwise, the query engine skips ahead to the appropriate next statement.
If you want that condition to apply to multiple statements, you must treat them as a statement block by enclosing them within the BEGIN
and END
keywords. Let’s look at a few IF
statements in action to better understand how the execution flow works. The following stored procedure includes the logic necessary to check whether the @CustID
variable is NULL
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
IF OBJECT_ID('dbo.GetCustomerInfo', 'p') IS NOT NULL DROP PROCEDURE dbo.GetCustomerInfo; GO CREATE PROCEDURE dbo.GetCustomerInfo (@CustID INT = NULL) AS IF @CustID IS NULL PRINT 'You must provide a customer ID.'; SELECT FirstName, LastName, City, StateProvinceName FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID; GO |
The IF
expression verifies whether the variable value is NULL
. If it is, the expression evaluates to TRUE
, and the query processor executes the PRINT
statement. Otherwise, the processor skips the PRINT
statement and jumps to the SELECT
statement. We can verify this by calling the procedure and passing in a valid BusinessEntityID
value:
1 |
EXEC GetCustomerInfo 1700; |
In this case, the EXECUTE
statement (and subsequently the procedure) returns the results shown in the following table:
FirstName |
LastName |
City |
StateProvinceName |
Rebecca |
Robinson |
Seaford |
Victoria |
Next let’s call the procedure without specifying a BusinessEntityID
value:
1 |
EXEC GetCustomerInfo; |
Because the @CustID
value is NULL
, the query engine executes the PRINT
statement and returns the following message, along with an empty result set for the customer data:
1 |
You must provide a customer ID. |
So far, this is all fairly straightforward, but now suppose our procedure includes additional statements that should run if the IF
expression evaluates to TRUE
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
IF OBJECT_ID('dbo.GetCustomerInfo', 'p') IS NOT NULL DROP PROCEDURE dbo.GetCustomerInfo; GO CREATE PROCEDURE dbo.GetCustomerInfo (@CustID INT = NULL) AS IF @CustID IS NULL PRINT 'You must provide a customer ID.'; PRINT 'Contact the sales rep for your region:'; SELECT FirstName, LastName, TerritoryName, EmailAddress FROM Sales.vSalesPerson WHERE JobTitle = 'Sales Representative'; SELECT FirstName, LastName, City, StateProvinceName FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID; GO |
This time around, we want to run two PRINT
statements as well as a SELECT
statement that returns a list of sales representations. When we call the stored procedure without specifying a BusinessEntityID
value, we receive the two messages, as expected:
1 2 |
You must provide a customer ID. Contact the sales rep for your region: |
In addition, we receive the following information about the sales reps:
FirstName |
LastName |
TerritoryName |
EmailAddress |
Michael |
Blythe |
Northeast |
michael9@adventure-works.com |
Linda |
Mitchell |
Southwest |
linda3@adventure-works.com |
Jillian |
Carson |
Central |
jillian0@adventure-works.com |
Garrett |
Vargas |
Canada |
garrett1@adventure-works.com |
Tsvi |
Reiter |
Southeast |
tsvi0@adventure-works.com |
Pamela |
Ansman-Wolfe |
Northwest |
pamela0@adventure-works.com |
Shu |
Ito |
Southwest |
shu0@adventure-works.com |
José |
Saraiva |
Canada |
josé1@adventure-works.com |
David |
Campbell |
Northwest |
david8@adventure-works.com |
Tete |
Mensa-Annan |
Northwest |
tete0@adventure-works.com |
Lynn |
Tsoflias |
Australia |
lynn0@adventure-works.com |
Rachel |
Valdez |
Germany |
rachel0@adventure-works.com |
Jae |
Pak |
United Kingdom |
jae0@adventure-works.com |
Ranjit |
Varkey Chudukatil |
France |
ranjit0@adventure-works.com |
Plus, we receive an empty result set for the customer information. Now let’s look what happens when we call the procedure and pass in a correct BusinessEntityID
value:
1 |
EXEC GetCustomerInfo 1700; |
As expected, we won’t receive the message about providing a customer ID, but we’ll still receive the message about contacting a sales rep as well as the result set that contains the sales rep information. Only then will we receive the result set that contains the customer information, which is the only data we really wanted.
The problem, of course, is that the query engine associates only the first PRINT
statement with the IF
condition. If we want to associate additional statements, we must enclose all the statements within a BEGIN...END
block:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
IF OBJECT_ID('dbo.GetCustomerInfo', 'p') IS NOT NULL DROP PROCEDURE dbo.GetCustomerInfo; GO CREATE PROCEDURE dbo.GetCustomerInfo (@CustID INT = NULL) AS IF @CustID IS NULL BEGIN PRINT 'You must provide a customer ID.'; PRINT 'Contact the sales rep for your region:'; SELECT FirstName, LastName, TerritoryName, EmailAddress FROM Sales.vSalesPerson WHERE JobTitle = 'Sales Representative'; END SELECT FirstName, LastName, City, StateProvinceName FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID; GO |
If we now call the stored procedure without specifying a customer ID, we’ll receive both messages, a result set that contains the sales rep information, and an empty result set for the customer information. However, if we do provide a valid customer ID, the query will return only the customer information.
One other note about using BEGIN...END
. Many developers will enclose even a single statement in BEGIN...END
because it makes the code more readable and makes it less likely that another developer will introduce an error by adding a statement without the block. In general, it doesn’t hurt to include the BEGIN...END
block even if you don’t need it in a particular case. In the long run it could save everyone a lot of trouble.
“When should I include an ELSE
clause within an IF statement?”
You can add an ELSE
clause to any IF
construction when you want to run specific statements in the event the Boolean expression (the IF
condition) evaluates to FALSE. You specify the ELSE
clause after the IF
statement block. An ELSE
clause let’s you better control your code’s logic and provides a mechanism for running statements outside of the IF...ELSE
structure.
Let’s again rewrite our stored procedure. This time, we’ll put the logic for retrieving the customer information in an ELSE
clause and then add a final PRINT
statement after the IF...ELSE
block:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
IF OBJECT_ID('dbo.GetCustomerInfo', 'p') IS NOT NULL DROP PROCEDURE dbo.GetCustomerInfo; GO CREATE PROCEDURE dbo.GetCustomerInfo (@CustID INT = NULL) AS IF @CustID IS NULL BEGIN PRINT 'You must provide a customer ID.'; PRINT 'Contact the sales rep for your region:'; SELECT FirstName, LastName, TerritoryName, EmailAddress FROM Sales.vSalesPerson WHERE JobTitle = 'Sales Representative'; END ELSE SELECT FirstName, LastName, City, StateProvinceName FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID; PRINT 'AdventureWorks Bicycles; ' + CONVERT(VARCHAR(30), GETDATE(), 109); GO |
As in the previous example, if the IF
expression evaluates to TRUE
, the statements within the BEGIN...END
block will run. However, this time around, if the expression evaluates to FALSE
, the SELECT
statement in the ELSE
clause will run. This approach let’s us be more precise with our logic while at the same time add other statements that are unrelated to the IF...ELSE
statements.
In this case, we’ve added a final PRINT
statement, which will run regardless of what statements run within the IF...ELSE
blocks. That means, when we call the function and pass in a valid customer ID, the procedure will return only the customer data along with the following information:
1 |
AdventureWorks Bicycles; Jan 18 2015 9:54:16:237AM |
However, if we call the procedure without specifying a customer ID, it will return the original two statements about providing a customer ID and contacting the sales rep as well as return the result set containing the sales rep information. The results will also include the statement about AdventureWorks Bicycles, but will not include the empty result set for the customer information. Because we’ve added the ELSE
clause, the clause’s SELECT
statement is no longer executed when the IF
expression evaluates to TRUE
. The query engine executes the ELSE
clause only if the related IF
expression evaluates to FALSE
.
“How do I nest IF
and IF…ELSE statements, if it’s even possible?”
Yes, it is possible to nest your IF
and IF...ELSE
statements, and you can do so to whatever degree necessary and practical. The primary limitation on nesting is available memory.
To demonstrate how nesting works, let’s return to the GetCustomerInfo
stored procedure. As you’ll recall from the last couples examples, we used the IF
conditions to determine which statements to run based on the value of the @CustID
variable. If the value was NULL
, we ran one set of statements. If the value was an integer, we ran a different statement. However, what if the value is a valid integer but not a valid customer ID?
To address this situation, we can add logic to our T-SQL code that handles the additional scenario. One way to do this is to nest an IF...ELSE
statement within the outer ELSE
clause, as shown in the following example:
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 |
IF OBJECT_ID('dbo.GetCustomerInfo', 'p') IS NOT NULL DROP PROCEDURE dbo.GetCustomerInfo; GO CREATE PROCEDURE dbo.GetCustomerInfo (@CustID INT = NULL) AS IF @CustID IS NULL BEGIN PRINT 'You must provide a customer ID.'; PRINT 'Contact the sales rep for your region:'; SELECT FirstName, LastName, TerritoryName, EmailAddress FROM Sales.vSalesPerson WHERE JobTitle = 'Sales Representative'; END ELSE BEGIN IF EXISTS(SELECT * FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID) SELECT FirstName, LastName, City, StateProvinceName FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID; ELSE PRINT 'No record matches the customer ID ' + CAST(@CustID AS VARCHAR(10)) + '.' END GO |
Notice we use the EXISTS
function to check whether the ID exists. As a result, the SELECT
statement in the original ELSE
clause will run only if the @CustID
value matches a value in underlying table. For example, suppose we pass in the value 101
when we call the stored procedure. If there is no match, it will return only the following message:
1 |
No record matches the customer ID 101. |
Because the first IF
expression evaluated to FALSE
, the ELSE
clause kicked in. However, the nested IF
expression in the outer ELSE
clause also evaluated to FALSE
, so the query engine jumped to the nested ELSE
clause and executed that PRINT
statement.
Clearly, the ability to nest IF...ELSE
statements provides a powerful mechanism for adding complex logic to our stored procedures and batches. Be wary of overdoing it, however. Memory is not the only issue to keep in mind. Too much conditional logic can result in the query optimizer having to continuously re-cache the plan, which can severely impact performance. Complex logic is often better suited to the application layer, letting each stored procedure focus on a single task.
“Is it possible to terminate a procedure’s execution from within an IF statement?”
Yes, it is possible, and often it’s a good strategy to do so. Why cause the query engine to do any more work than necessary? The sooner you can pull out, the better. And T-SQL supports a great tool for unconditionally exiting a batch, statement block, or stored procedure: the RETURN
statement.
You can add a RETURN
statement to whatever part of the T-SQL logic that you want to end when and if you reach that part of the code. Let’s return to a basic version of our GetCustomerInfo
stored procedure:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
IF OBJECT_ID('dbo.GetCustomerInfo', 'p') IS NOT NULL DROP PROCEDURE dbo.GetCustomerInfo; GO CREATE PROCEDURE dbo.GetCustomerInfo (@CustID INT = NULL) AS IF @CustID IS NULL BEGIN PRINT 'You must provide a customer ID.'; PRINT 'Contact the sales rep for your region:'; SELECT FirstName, LastName, TerritoryName, EmailAddress FROM Sales.vSalesPerson WHERE JobTitle = 'Sales Representative'; END SELECT FirstName, LastName, City, StateProvinceName FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID; GO |
Even if the IF
expression evaluates to TRUE
, the final SELECT
statement runs and with it comes an empty result set. One way to avoid this, as we saw earlier, is to add an ELSE
clause. However, we can also terminate the stored procedure after the IF
statement block runs by adding the RETURN
statement:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
IF OBJECT_ID('dbo.GetCustomerInfo', 'p') IS NOT NULL DROP PROCEDURE dbo.GetCustomerInfo; GO CREATE PROCEDURE dbo.GetCustomerInfo (@CustID INT = NULL) AS IF @CustID IS NULL BEGIN PRINT 'You must provide a customer ID.'; PRINT 'Contact the sales rep for your region:'; SELECT FirstName, LastName, TerritoryName, EmailAddress FROM Sales.vSalesPerson WHERE JobTitle = 'Sales Representative'; RETURN; END SELECT FirstName, LastName, City, StateProvinceName FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID; GO |
If the IF
expression evaluates to TRUE
all the statements within the BEGIN...END
block will run, just like before; however, when the query engine hits RETURN
, it will immediately stop processing the stored procedure and exit. No other statements will be executed.
Although this might not always be the strategy you want to employ for controlling your code’s logical flow, when it is, the RETURN
statement is easy to implement and can be used anywhere in your code that makes sense. For example, the following procedure definition also includes RETURN
in the nested IF
statement:
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 |
IF OBJECT_ID('dbo.GetCustomerInfo', 'p') IS NOT NULL DROP PROCEDURE dbo.GetCustomerInfo; GO CREATE PROCEDURE dbo.GetCustomerInfo (@CustID INT = NULL) AS IF @CustID IS NULL BEGIN PRINT 'You must provide a customer ID.'; PRINT 'Contact the sales rep for your region:'; SELECT FirstName, LastName, TerritoryName, EmailAddress FROM Sales.vSalesPerson WHERE JobTitle = 'Sales Representative'; RETURN; END ELSE BEGIN IF EXISTS(SELECT * FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID) BEGIN SELECT FirstName, LastName, City, StateProvinceName FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID; RETURN; END ELSE PRINT 'No record matches the customer ID ' + CAST(@CustID AS VARCHAR(10)) + '.' END GO |
As before, the query engine terminates the procedure whenever it encounters RETURN
and does not run any more statements.
“Which is better to use, a CASE expression or an IF (or IF…ELSE) statement?”
This question comes up often, in part because Microsoft documentation used to group CASE
in with its control-of-flow language elements. But that has changed because CASE
is not a control-of-flow language element, but rather a method for evaluating individual expressions.
So it’s not quite fair to compare IF
and CASE
. They serve different purposes and perform different types of operations. The IF
statement controls the flow of your code’s logic; whereas, CASE
is used as part of an expression to evaluate a particular value. One way to think of this is it that IF
works at the statement level and CASE
works at the value level, usually within a SELECT
clause or WHERE
clause. The CASE
expression evaluates a set of conditions sequentially and stops when one of the conditions evaluates to TRUE
. With an IF
or IF...ELSE
statement, you can better control which elements get executed and in what order that execution occurs.
That said, the two can sometimes be used to achieve the same results. For example, the following T-SQL code declares a variable and uses an IF
statement to verify it’s value:
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 |
DECLARE @a VARCHAR(30) = 'one' IF @a = 'one' BEGIN PRINT 'The variable value is 1.'; RETURN; END IF @a = 'two' BEGIN PRINT 'The variable value is 2.'; RETURN; END IF @a = 'three' BEGIN PRINT 'The variable value is 3.'; RETURN; END IF @a = 'four' BEGIN PRINT 'The variable value is 4.'; RETURN; END IF @a NOT IN ('one', 'two', 'three', 'four') BEGIN PRINT 'The variable value is not 1-4.'; RETURN; END |
Each IF
condition is evaluated until one evaluates to TRUE
. The query engine will run the associated PRINT
statement and RETURN
statement, thus ending execution. But we can achieve the same results by creating a CASE
expression within a SELECT
clause:
1 2 3 4 5 6 7 8 9 10 11 |
DECLARE @b VARCHAR(30) = 'two' SELECT @b = CASE WHEN @b = 'one' THEN 'The variable value is 1.' WHEN @b = 'two' THEN 'The variable value is 2.' WHEN @b = 'three' THEN 'The variable value is 3.' WHEN @b = 'four' THEN 'The variable value is 4.' ELSE 'The variable value is not 1-4.' END; PRINT @b; |
Once again, the query engine starts by evaluating each condition, but stops when it reaches the condition that evaluates to TRUE
. Even with this simple example, you can see how much easier it is to create a CASE
expression. If you can use CASE
, that’s a good way to go. But if you need to control the flow of the statement logic, you should use IF
or another control-of-flow language construct. A CASE
expression is great for doing the job it was meant for, but it’s not meant to control statement execution.
Rather than comparing whether CASE
is better than IF
, or vice versa, think in terms of picking the right tool for the right job. Usually, you’ll want to use CASE
to evaluate and return individual values and use IF
to control the flow of statement execution.
“Can I use control-of-flow language to do an upsert in SQL Server?”
For those not familiar with the term, upsert refers to code that includes logic to either update a row or insert a row, depending on whether that row exists. As with other data modification operations, you can use control-of-flow statement elements to do an upsert.
One of the most common ways to do this is to use an IF
expression to check for the row’s existence and then execute your statements accordingly. Let’s start with a simple temporary table to demonstrate how this works:
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 |
CREATE TABLE #products (ProdID INT, ListPrice MONEY); INSERT INTO #products VALUES (101, 199.99), (102, 299.99), (103, 399.99); Now let's create our data modification statements, based on an IF...ELSE construction to control the execution flow: DECLARE @ProdID INT = 103; DECLARE @ListPrice MONEY = 388.88 IF EXISTS(SELECT * FROM #products WHERE ProdID = @ProdID) BEGIN UPDATE #products SET ListPrice = @ListPrice WHERE ProdID = @ProdID END ELSE BEGIN INSERT INTO #products VALUES(@ProdID, @ListPrice) END; SELECT * FROM #products WHERE ProdID = @ProdID; |
The IF
expression uses the EXISTS
function to determine whether the row exists (based on the product ID). If it does exist, the UPDATE
statement is executed. Otherwise, the database engine performs the INSERT
statement defined in the ELSE
clause.
In this case, the value does exist so the ListPrice
value is updated and the SELECT
statement returns a value of 388.88
, as expected. Had we set the value of the @ProdID
variable to one that doesn’t exist, a new row would have instead been inserted into the table.
This, of course, is a very simple example, but it demonstrates what is a common approach to using an IF...ELSE
statement to perform an upsert. And you can just as easily use control-of-flow language for other types of data modifications.
But you need to be careful when you start to mix-and-match in this way. For example, the solution above could result in concurrency issues, either in the form of deadlocks or multiple insertions of the same data. In addition, this approach might not perform as well as other options because of unnecessary table or index scans. Workarounds to this approach might include using the @@rowcount
system variable to test whether the update has worked or using a MERGE
statement to join the table to itself. The point is, you must be especially diligent when using control-of-flow language to modify data.
“I don’t get how a WHILE
loop works. Can you help make sense of what’s going on here?”
The WHILE
loop provides a structure for repeatedly executing a set of statements as long as the loop’s condition evaluates to TRUE
. The WHILE
loop lets you add the logic necessary to limit the number of executions when you don’t know that number in advance. Let’s look at a few examples to get a sense of how these loops work.
We’ll start simply by first demonstrating the logic behind the execution flow when using a WHILE
loop. The following T-SQL declares a variable and then executes a loop based on the variable’s value:
1 2 3 4 5 6 7 |
DECLARE @c INT = 100; WHILE @c <= 104 BEGIN PRINT @c; SET @c = @c + 1; END; |
The loop begins with the WHILE
keyword, followed by the condition that is evaluated each time the loop runs. If the condition evaluates to TRUE
, the query engine executes the T-SQL in the statement block that follows the condition. If the condition evaluates to FALSE
, the query engine exits the loop and continues on to any code after the statement block.
In this case, the condition will evaluate to TRUE
five times, which means the loop will run five times. When the variable value exceeds 104
, the loop ends. The following statement shows the results that the statements return:
1 2 3 4 5 |
100 101 102 103 104 |
You can further control the logic within your loop by adding a BREAK
statement in any place where the query engine should exit the loop:
1 2 3 4 5 6 7 8 9 |
DECLARE @d INT = 100; WHILE @d <= 104 BEGIN PRINT @d; SET @d = @d + 1; IF @d = 103 BREAK; END; |
This time around, we’ve added an IF
statement that specifies that the query engine should break out of the loop if the variable value equals 103
. Because we’ve added the IF
and BREAK
conditions, our loop now returns only the following results;
1 2 3 |
100 101 102 |
As you can see, query execution stopped when the variable value hit 103
.
You can also add a CONTINUE
statement to your WHILE
loop to further control the execution. The CONTINUE
statement causes the query engine to restart the loop from wherever you’ve added CONTINUE
:
1 2 3 4 5 6 7 8 9 10 |
DECLARE @e INT = 100; WHILE @e <= 104 BEGIN PRINT @e; SET @e = @e + 1; CONTINUE; IF @e = 103 BREAK; END; |
Because I’ve included the CONTINUE
statement where I did, the statement will never reach the final IF
block. Instead, each time the WHILE
condition evaluates to TRUE
, the statement block will be executed up to CONTINUE
and then start over, giving us the following results:
1 2 3 4 5 |
100 101 102 103 104 |
Of course, all we’ve done is go full circle, receiving the same results we received without using CONTINUE
and BREAK
, but these simple examples should help demonstrate the basic concepts of a WHILE
loop.
But now suppose we want to do something a little more involved. Let’s start by creating a small temporary table based on data from the Product
table in the AdventureWorks2014
database:
1 2 3 4 5 6 7 8 9 |
SELECT ProductID, Name AS ProductName, StandardCost AS Wholesale, ListPrice As Retail INTO #PriceyProducts FROM Production.Product WHERE ListPrice > 2999.99; SELECT * FROM #PriceyProducts |
The SELECT
statement returns the results shown in the following table:
ProductID |
ProductName |
Wholesale |
Retail |
749 |
Road-150 Red, 62 |
2171.2942 |
3578.27 |
750 |
Road-150 Red, 44 |
2171.2942 |
3578.27 |
751 |
Road-150 Red, 48 |
2171.2942 |
3578.27 |
752 |
Road-150 Red, 52 |
2171.2942 |
3578.27 |
753 |
Road-150 Red, 56 |
2171.2942 |
3578.27 |
771 |
Mountain-100 Silver, 38 |
1912.1544 |
3399.99 |
772 |
Mountain-100 Silver, 42 |
1912.1544 |
3399.99 |
773 |
Mountain-100 Silver, 44 |
1912.1544 |
3399.99 |
774 |
Mountain-100 Silver, 48 |
1912.1544 |
3399.99 |
775 |
Mountain-100 Black, 38 |
1898.0944 |
3374.99 |
776 |
Mountain-100 Black, 42 |
1898.0944 |
3374.99 |
777 |
Mountain-100 Black, 44 |
1898.0944 |
3374.99 |
778 |
Mountain-100 Black, 48 |
1898.0944 |
3374.99 |
Now suppose we want to increase the retail prices in 10% percent increments, but only as long as the average net price remains under $2500. At the same time, the retail price must always remain under $4999.99. We can create a WHILE
loop that increments the price while ensuring we stay within our parameters:
1 2 3 4 5 6 7 8 9 10 11 12 |
DECLARE @count INT = 0 WHILE (SELECT AVG(Retail - Wholesale) FROM #PriceyProducts) < 2500 BEGIN UPDATE #PriceyProducts SET Retail = Retail * 1.1 SET @count = @count + 1; IF (SELECT MAX(Retail) FROM #PriceyProducts) < 4999.99 CONTINUE; ELSE BREAK; END; PRINT 'The WHILE loop ran ' + CAST(@count AS VARCHAR(10)) + ' times.' |
The WHILE
condition checks whether the average net price is under $2500. If the condition evaluates to TRUE
, the statement block runs and increases the retail prices by 10%. The statement block also includes an IF
statement, which checks whether the highest retail price is under $4999.00. If it is, the loop starts over. Should the IF
condition evaluate to FALSE
, the query engine breaks out of the loop and jumps to the final PRINT
statement.
I included the @count
variable only to count the number of times the WHILE
loop runs, which in this case, is three times, as the following results show:
1 |
The WHILE loop ran 3 times. |
If we were now to view the data in the #PriceyProducts
table, we would see the following results:
ProductID |
ProductName |
Wholesale |
Retail |
749 |
Road-150 Red, 62 |
2171.2942 |
4762.6774 |
750 |
Road-150 Red, 44 |
2171.2942 |
4762.6774 |
751 |
Road-150 Red, 48 |
2171.2942 |
4762.6774 |
752 |
Road-150 Red, 52 |
2171.2942 |
4762.6774 |
753 |
Road-150 Red, 56 |
2171.2942 |
4762.6774 |
771 |
Mountain-100 Silver, 38 |
1912.1544 |
4525.3867 |
772 |
Mountain-100 Silver, 42 |
1912.1544 |
4525.3867 |
773 |
Mountain-100 Silver, 44 |
1912.1544 |
4525.3867 |
774 |
Mountain-100 Silver, 48 |
1912.1544 |
4525.3867 |
775 |
Mountain-100 Black, 38 |
1898.0944 |
4492.1117 |
776 |
Mountain-100 Black, 42 |
1898.0944 |
4492.1117 |
777 |
Mountain-100 Black, 44 |
1898.0944 |
4492.1117 |
778 |
Mountain-100 Black, 48 |
1898.0944 |
4492.1117 |
We can verify our results by running the following SELECT
statement to determine the current average net price:
1 |
SELECTAVG(Retail - Wholesale) FROM #PriceyProducts |
The statement returns the value 2598.9164
, which exceeds the $2500. It was at this point that the query engine exited the WHILE
loop. In addition, as we can see in the results, the maximum Retail
value is 4762.6774
, which is below the 4999.99
limit.
“How do I add a pause or wait time to my T-SQL in order to delay execution?”
SQL Server supports a handy control-of-flow statement called WAITFOR
, which stops code execution until a specified time or for a specified amount of time. A WAITFOR
statement can be handy to deal with those unusual circumstances when you need to work around legacy systems or network limitations. For example, one of your SQL Server jobs might trigger a second job that can run only at specific time because of network constrains.
WAITFOR
can also be used in development and testing environments to help generate specific responses. For example, suppose we’ve created the following temporary table:
1 2 3 4 5 6 7 |
SELECT ProductID AS ProdID, Name AS ProductName, StandardCost AS Wholesale, ListPrice As Retail INTO ##awproducts FROM Production.Product WHERE FinishedGoodsFlag = 1; |
Now suppose we want to test concurrency by running two separate operations, each in its own session, to verify whether we’ll get a dirty read. We can start the first session, but include a WAITFOR
statement to delay the transaction by five seconds:
1 2 3 4 5 6 7 8 9 |
BEGIN TRANSACTION; UPDATE ##awproducts SET Retail = Retail * 1.2 WHERE ProdID = 680; WAITFOR DELAY '00:00:05' ROLLBACK TRANSACTION; |
The WAITFOR
statement includes the DELAY
keyword to indicate that we want to delay the execution, rather than use the TIME
option to specify a specific time. During these five seconds, we run a SELECT
statement from our second session, but first set the isolation level to READ
UNCOMMITTED
:
1 2 3 4 |
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT Retail FROM ##awproducts WHERE ProdID = 680; |
Because the first transaction is still active, the SELECT
statement returns a value of 1717.80
, the value that has been updated in the first session. However, if we run the SELECT
statement again, after the first transaction completes (and rolls back), the statement returns the value 1431.50
. In other words, the first time we ran our SELECT
statement we received a dirty read.
We can also specify the exact time we want to end our delay by using the TIME
option:
1 2 3 |
DECLARE @time DATETIME = DATEADD(S, 5, GETDATE()); WAITFOR TIME @time; PRINT 'The waiting is over.'; |
Once again, we’re waiting only five seconds, but you get the point. You can provide a time rather than a range, giving you another way to control execution.
“Is it possible to skip from one section of your T-SQL code to another?”
In T-SQL you can use a GOTO
statement to jump to another part of the code, as long as you’ve assigned a label to the destination. A label is merely an identifier that provides a heading for the targeted section of code. For example, the following T-SQL includes several labeled blocks of code with several references to them:
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 |
DECLARE @count INT = 1; IF @count = 1 GOTO goto_one; ELSE IF @count = 2 GOTO goto_two; ELSE IF @count = 3 GOTO goto_three; ELSE GOTO goto_four; goto_one: PRINT 'This is goto_one.' GOTO goto_four; goto_two: PRINT 'This is goto_two.' GOTO goto_four; goto_three: PRINT 'This is goto_three.' GOTO goto_four; goto_four: RETURN |
When you reference a label, you provide only the label name along with the GOTO
keyword. However, when you assign a label to a bock of code, you must also include a colon. When the query engine encounters a GOTO
statement, it jumps to the label identified in the statement. For example, the initial IF
statement includes a GOTO
statement that points to the goto_one
label. As a result, if the @count
variable has a value of 1
, the query engine will jump to the goto_one
label, execute the PRINT
statement associated with that label, and return the following results.
1 |
This is goto_one. |
Of course, you’re likely to want to use the GOTO
statement for more practical purposes than in the example above. For example, suppose your T-SQL includes a number of IF
statements that take different steps. Regardless of which condition evaluates to TRUE
, you want to run a final INSERT
statement that logs the event. You can do something similar to the following:
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 |
CREATE TABLE #UsageLog (LogID INT IDENTITY, Usage VARCHAR(20), LogTime DATETIME DEFAULT GETDATE()); DECLARE @CustID INT = 170, @usage VARCHAR(20) = ''; IF @CustID IS NULL BEGIN PRINT 'You must provide a customer ID.'; PRINT 'Contact the sales rep for your region:'; SELECT FirstName, LastName, TerritoryName, EmailAddress FROM Sales.vSalesPerson WHERE JobTitle = 'Sales Representative'; SET @usage = 'ID null'; GOTO log_usage; END ELSE IF EXISTS(SELECT * FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID) BEGIN SELECT FirstName, LastName, City, StateProvinceName FROM Sales.vIndividualCustomer WHERE BusinessEntityID = @CustID; SET @usage = 'ID valid'; GOTO log_usage; END ELSE BEGIN PRINT 'No record matches the customer ID ' + CAST(@CustID AS VARCHAR(10)) + '.' SET @usage = 'ID invalid'; GOTO log_usage; END log_usage: INSERT INTO #UsageLog (Usage) VALUES(@usage); |
For each possible condition, the statement block ends with a GOTO
statement that points to the log_usage
label, so no matter what happens elsewhere, the last statement here will run. Although we’ve included only a simple INSERT
statement with out label, you can use this strategy to avoid having to recode more complex logic in multiple places.
“What’s the simplest way to incorporate error handling into my stored procedures?”
Since the release SQL Server 2005, error handling has been fairly easy to incorporate in your T-SQL code through the use of TRY...CATCH
blocks. The idea behind this is that you put the main body of your code in the TRY
block and put the error handling in the CATCH
block. The statements in the CATCH
block will run only if the query engine encounters an error in the TRY
block.
When Microsoft first added support for TRY...CATCH
error handling, you had to use a RAISERROR
statement to catch and throw the error, should one occur. But that changed in SQL Server 2012. You can now use a THROW
statement, which is simpler to use and more accurate.
To demonstrate how all this works, let’s start with a simple temporary table and add a few rows of data:
1 2 3 4 5 6 7 |
CREATE TABLE #products (ProdID INT PRIMARY KEY, ListPrice MONEY); INSERT INTO #products VALUES (101, 199.99), (102, 299.99), (103, 399.99); |
Now let’s create a stored procedure that adds a row to the table, with the values coming from two input parameters:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
IF OBJECT_ID('dbo.AddProduct', 'p') IS NOT NULL DROP PROCEDURE dbo.AddProduct; GO CREATE PROCEDURE dbo.AddProduct (@ProdID INT, @ListPrice MONEY) AS BEGIN TRY BEGIN TRANSACTION; INSERT INTO #products VALUES (@ProdID, @ListPrice); COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; PRINT 'The stored procedure generated the following error:'; THROW; END CATCH; GO |
First, notice that the procedure definition includes the TRY...CATCH
blocks. In the TRY
block, we include the primary T-SQL needed to begin the transaction, insert the vales into the table, and commit the transaction. If the statements in the TRY
block run with no problem, the operation completes and the database engine exists the TRY...CATCH
blocks. However, if the TRY
block generates an error, the database engine skips to the CATCH
block where the error can be handled.
In this example, the CATCH
block includes three statements. The first is an IF
statement that rolls back the transaction if the @@TRANCOUNT
system variable value is greater than 0
, which means a transaction is active in the current session. Next, it includes a PRINT
statement, which I’ve included primarily to verify that the CATCH
block statements are being executed should an error occur in the TRY
block. Finally, I add a THROW
statement to catch and return the error.
Let’s look at the stored procedure in action to demonstrate how everything works. First, we’ll use the procedure to add a row to our temporary table and then run a SELECT
statement:
1 2 3 |
EXEC AddProduct 104, 499.99; SELECT * FROM #products; |
Not surprisingly, the query returns four rows, including a row for product 104, as shown in the following results:
ProdID |
ListPrice |
101 |
199.99 |
102 |
299.99 |
103 |
399.99 |
104 |
499.99 |
Now let’s try to run the same EXECUTE
command again:
1 |
EXEC AddProduct 104, 499.99; |
Because we’d be violating the primary key by adding this row, the query engine baulks and returns the following results:
The stored procedure generated the following error:
1 2 |
Msg 2627, Level 14, State 1, Procedure AddProduct, Line 1058 Violation of PRIMARY KEY constraint 'PK__#product__042785C5164AE135'. Cannot insert duplicate key in object 'dbo.#products'. The duplicate key value is (104). |
Notice that the results include the message from the PRINT
command, along with the error details provided by the THROW
statement.
The THROW
statement also let’s you define your own error number, message, and state, as shown in the following example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
IF OBJECT_ID('dbo.AddProduct', 'p') IS NOT NULL DROP PROCEDURE dbo.AddProduct; GO CREATE PROCEDURE dbo.AddProduct (@ProdID INT, @ListPrice MONEY) AS BEGIN TRY BEGIN TRANSACTION; INSERT INTO #products VALUES (@ProdID, @ListPrice); COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; PRINT 'The stored procedure generated the following error:'; THROW 55555, 'SQL Server threw an exception because of the way you called your stored procedure.', 1; END CATCH; GO |
If we were to once again try to add product 104, out results would now look like the following:
1 2 3 |
The stored procedure generated the following error: Msg 55555, Level 16, State 1, Procedure AddProduct, Line 1111 SQL Server threw an exception because of the way you called your stored procedure. |
It’s up to you whether to go with the generic message or create your own. The THROW
statement on its own is a powerful tool, and that might be all you need. There is more to error handling, though, so you might benefit from an article I wrote a while back on the subject: “Handling Errors in SQL Server 2012.”
Load comments