5. Handling Errors
When you deal with an outside
resource such as a database, you need to protect your code with a basic
amount of error-handling logic. Even if you've avoided every possible
coding mistake, you still need to defend against factors outside your
control—for example, if the database server isn't running or the network
connection is broken.
You can count on the
SqlDataSource to properly release any resources (such as connections) if
an error occurs. However, the underlying exception won't be handled.
Instead, it will bubble up to the page and derail your processing. As
with any other unhandled exception, the user will receive a cryptic
error message or an error page. This design is unavoidable—if the
SqlDataSource suppressed exceptions, it could hide potential problems
and make debugging extremely difficult. However, it's a good idea to
handle the problem in your web page and show a more suitable error
message.
To do this, you need to handle the data source event that occurs immediately after
the error. If you're performing a query, that's the Selected event. If
you're performing an update, delete, or insert operation, you would
handle the Updated, Deleted, or Inserted event instead. (If you don't
want to offer customized error messages, you could handle all these
events with the same event handler.)
In the event handler,
you can access the exception object through the
SqlDataSourceStatusEventArgs.Exception property. If you want to prevent
the error from spreading any further, simply set the
SqlDataSourceStatusEventArgs.ExceptionHandled property to True. Then,
make sure you show an appropriate error message on your web page to
inform the user that the command was not completed.
Here's an example:
Protected Sub sourceProducts_Selected(ByVal sender As Object, _
ByVal e As SqlDataSourceStatusEventArgs) Handles sourceProducts.Selected
If e.Exception IsNot Nothing Then
lblError.Text = "An exception occurred performing the query."
' Consider the error handled.
e.ExceptionHandled = True
End If
End Sub
6. Updating Records
Selecting data is
only half the equation. The SqlDataSource can also apply changes. The
only catch is that not all controls support updating. For example, the
humble ListBox doesn't provide any way for the user to edit values,
delete existing items, or insert new ones. Fortunately, ASP.NET's rich
data controls—including the GridView, DetailsView, and FormView—all have
editing features you can switch on.
Before you can switch on the
editing features in a given control, you need to define suitable
commands for the operations you want to perform in your data source.
That means supplying commands for inserting (InsertCommand), deleting
(DeleteCommand), and updating (UpdateCommand). If you know you will
allow the user to perform only certain operations (such as updates) but
not others (such as insertions and deletions), you can safely omit the
commands you don't need.
You define the
InsertCommand, DeleteCommand, and UpdateCommand in the same way you
define the command for the SelectCommand property—by using a
parameterized query. For example, here's a revised version of the
SqlDataSource for product information that defines a basic update
command to update every field:
<asp:SqlDataSource ID="sourceProductDetails" runat="server"
ProviderName="System.Data.SqlClient"
ConnectionString="<%$ ConnectionStrings:Northwind %>"
SelectCommand="SELECT ProductID, ProductName, UnitPrice, UnitsInStock,
UnitsOnOrder, ReorderLevel, Discontinued FROM Products WHERE ProductID=@ProductID"
UpdateCommand="UPDATE Products SET ProductName=@ProductName, UnitPrice=@UnitPrice,
UnitsInStock=@UnitsInStock, UnitsOnOrder=@UnitsOnOrder, ReorderLevel=@ReorderLevel,
Discontinued=@Discontinued WHERE ProductID=@ProductID">
<SelectParameters>
<asp:ControlParameter ControlID="lstProduct" Name="ProductID"
PropertyName="SelectedValue" />
</SelectParameters>
</asp:SqlDataSource>
In this example, the parameter
names aren't chosen arbitrarily. As long as you give each parameter the
same name as the field it affects, and preface it with the @ symbol (so
ProductName becomes @ProductName), you don't need to define the
parameter. That's because the ASP.NET data controls automatically submit
a collection of parameters with the new values before triggering the
update. Each parameter in the collection uses this naming convention,
which is a major time-saver.
You also need to give the user
a way to enter the new values. Most rich data controls make this fairly
easy—with the DetailsView, it's simply a matter of setting the
AutoGenerateEditButton property to True, as shown here:
<asp:DetailsView ID="DetailsView1" runat="server"
DataSourceID="sourceProductDetails" AutoGenerateEditButton="True" />
Now when you run the page,
you'll see an edit link. When clicked, this link switches the
DetailsView into edit mode. All fields are changed to edit controls
(typically text boxes), and the Edit link is replaced with an Update
link and a Cancel link (see Figure 4).
Clicking the Cancel link
returns the row to its initial state. Clicking the Update link triggers
an update. The DetailsView extracts the field values, uses them to set
the parameters in the SqlDataSource.UpdateParameters collection, and
then triggers the SqlDataSource.UpdateCommand to apply the change to the
database. Once again, you don't have to write any code.
You can create
similar parameterized commands for the DeleteCommand and InsertCommand.
To enable deleting and inserting, you need to set the
AutoGenerateDeleteButton and AutoGenerateInsertButton properties of the
DetailsView to True.
6.1. Strict Concurrency Checking
The update command in the previous example matches the record based on its ID. You can tell this by examining the Where clause:
UpdateCommand="UPDATE Products SET ProductName=@ProductName, UnitPrice=@UnitPrice,
UnitsInStock=@UnitsInStock, UnitsOnOrder=@UnitsOnOrder, ReorderLevel=@ReorderLevel,
Discontinued=@Discontinued WHERE ProductID=@ProductID"
The problem with this approach
is that it opens the door to an update that overwrites the changes of
another user, if these changes are made between the time your page is
requested and the time your page commits its update.
For example, imagine Chen and
Lucy are viewing the same table of product records. Lucy commits a
change to the price of a product. A few seconds later, Chen commits a
name change to the same product record. Chen's update command not only
applies the new name but it also overwrites all the other fields with
the values from Chen's page—replacing the price Lucy entered with the
price from the original page.
One way to solve this problem is to use an approach called match-all-values
concurrency. In this situation, your update command attempts to match
every field. As a result, if the original record has changed, the update
command won't find it and the update won't be performed at all. So in
the scenario described previously, using the match-all-values strategy,
Chen would receive an error when he attempts to apply the new product
name, and he would need to edit the record and apply the change again.
To use this approach, you need
to add a Where clause that tries to match every field. Here's what the
modified command would look like:
UpdateCommand="UPDATE Products SET ProductName=@ProductName, UnitPrice=@UnitPrice,
UnitsInStock=@UnitsInStock, UnitsOnOrder=@UnitsOnOrder, ReorderLevel=@ReorderLevel,
Discontinued=@Discontinued WHERE ProductID=@ProductID AND
ProductName=@original_ProductName AND UnitPrice=@original_UnitPrice AND
UnitsInStock=@original_UnitsInStock AND UnitsOnOrder=@original_UnitsOnOrder AND
ReorderLevel=@original_ReorderLevel AND Discontinued=@original_Discontinued"
Although this makes
sense conceptually, you're not finished yet. Before this command can
work, you need to tell the SqlDataSource to maintain the old values from
the data source and to give them parameter names that start with
original_. You do this by setting two properties. First, set the
SqlDataSource.ConflictDetection property to
ConflictOptions.CompareAllValues instead of
ConflictOptions.OverwriteChanges (the default). Next, set the
long-winded OldValuesParameterFormatString property to the text
"original_{0}". This tells the SqlDataSource to insert the text
original_ before the field name to create the parameter that stores the
old value. Now your command will work as written.
The SqlDataSource doesn't
raise an exception to notify you if no update is performed. So, if you
use the command shown in this example, you need to handle the
SqlDataSource.Updated event and check the
SqlDataSourceStatusEventArgs.AffectedRows property. If it's 0, no
records have been updated, and you should notify the user about the
concurrency problem so the update can be attempted again, as shown here:
Protected Sub sourceProductDetails_Updated(ByVal sender As Object, _
ByVal e As SqlDataSourceStatusEventArgs) Handles sourceProductDetails.Updated
If e.AffectedRows = 0 Then
lblInfo.Text = "No update was performed. " & _
"A concurrency error is likely, or the command is incorrectly written."
Else
lblInfo.Text = "Record successfully updated."
End If
End Sub
Figure 5
shows the result you'll get if you run two copies of this page in two
separate browser windows, begin editing in both of them, and then try to
commit both updates.
Matching every field is
an acceptable approach for small records, but it isn't the most
efficient strategy if you have tables with huge amounts of data. In this
situation, you have two possible solutions: you can match some of the
fields (leaving out the ones with really big values) or you can add a
timestamp field to your database table, and use that for concurrency
checking.
Timestamps
are special fields that the database uses to keep track of the state of
a record. Whenever any change is made to a record, the database engine
updates the timestamp field, giving it a new, automatically generated
value. The purpose of a timestamp field is to make strict concurrency
checking easier. When you attempt to perform an update to a table that
includes a timestamp field, you use a Where clause that matches the
appropriate unique ID value (like ProductID) and the timestamp field:
UpdateCommand="UPDATE Products SET ProductName=@ProductName, UnitPrice=@UnitPrice,
UnitsInStock=@UnitsInStock, UnitsOnOrder=@UnitsOnOrder,
ReorderLevel=@ReorderLevel, Discontinued=@Discontinued
WHERE ProductID=@ProductID AND RowTimestamp=@RowTimestamp"
The database engine uses the
ProductID to look up the matching record. Then, it attempts to match the
timestamp in order to update the record. If the timestamp matches, you
know the record hasn't been changed. The actual value of the timestamp isn't important, because that's controlled by the database. You just need to know whether it's changed.
Creating
a timestamp is easy. In SQL Server, you create a timestamp field using
the timestamp data type. In other database products, timestamps are
sometimes called row versions.