Pro ASP.NET 2.0 In CSharp 2005 (2005) [eng]
.pdf
378 C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S
The second column contains an embedded GridView of products, with two bound columns. Here’s an excerpted listing that omits the style-related attributes:
<asp:TemplateField HeaderText="Products"> <ItemStyle VerticalAlign="Top"></ItemStyle> <ItemTemplate>
<asp:GridView runat="server"> <Columns>
<asp:BoundField DataField="ProductName" HeaderText="Product Name"></asp:BoundColumn> <asp:BoundField DataField="UnitPrice"
HeaderText="Unit Price" DataFormatString="{0:C}"></asp:BoundColumn> </Columns>
</asp:GridView>
</ItemTemplate>
</asp:TemplateField>
Now all you need to is create two data sources, one for retrieving the list of categories and the other for retrieving all products in a specified category. The first query fills the parent GridView, and the second query is called multiple times to fill the child GridView.
You can bind the first grid directly to the data source, as shown here:
<asp:GridView id="gridMaster" runat="server" DataKeyNames="CategoryID" DataSourceID="sourceCategories" OnRowDataBound="gridMaster_RowDataBound" ... >
This part of the code is typical. The trick is to bind the child GridView controls. If you leave out this step, the child GridView controls won’t appear.
To bind the child GridView controls, you need to react to the GridView.RowDataBound event, which fires every time a row is generated and bound to the parent GridView. At this point, you can retrieve the child DataGrid control from the second column and bind it to the product information by programmatically calling the Select() method of the data source. To ensure that you show only the products in the current category, you must also retrieve the CategoryID field for the current item and pass it as a parameter. Here’s the code you need:
protected void gridMaster_RowDataBound(object sender, GridViewRowEventArgs e)
{
// Look for data items.
if (e.Row.RowType == DataControlRowType.DataRow)
{
//Retrieve the GridView control in the second column. GridView gridChild = (GridView)e.Row.Cells[1].Controls[1];
//Set the CategoryID parameter so you get the products
//in the current category only.
string catID = gridMaster.DataKeys[e.Row.DataItemIndex].Value.ToString(); sourceProducts.SelectParameters[0].DefaultValue = catID;
// Get the data object from the data source.
object data = sourceProducts.Select(DataSourceSelectArguments.Empty);
// Bind the grid. gridChild.DataSource = data; gridChild.DataBind();
}
}
Figure 10-21 shows the resulting grid.
C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S |
379 |
Figure 10-21. A parent grid with embedded child grids
Serving Images from a Database
The data examples in this chapter retrieve text, numeric, and date information. However, databases often have the additional challenge of storing binary data such as pictures. For example, you might have a Products table that contains pictures of each item in a binary field. Retrieving this data in an ASP.NET web page is fairly easy, but displaying it is not as simple.
The basic problem is that in order to show an image in an HTML page, you need to add an image tag that links to a separate image file through the src attribute. Here’s an example:
<img src="myfile.gif" />
Unfortunately, this isn’t much help if you need to show image data dynamically. Although you can set the src attribute in code, you have no way to set the image content programmatically. You could first save the data to an image file on the web server’s hard drive, but that approach would be dramatically slower, waste space, and raise the possibility of concurrency errors if multiple requests are being served at the same time and they are all trying to write the same file.
You can solve this problem in two ways. One approach is to store all your images in separate files. Then your database record simply needs to store the filename, and you can bind the filename to a server-side image. This is a perfectly reasonable solution, but it doesn’t help in situations where you want to store images in the database so you can take advantage of the abilities of the RDBMS to cache data, log usage, and back up everything.
380 C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S
In these situations, the solution is to use a separate ASP.NET resource that returns the binary data directly. You can then use this binary data in other web pages in controls. To tackle this task, you also need to step outside the data binding and write custom ADO.NET code. The following sections will develop the solution you need piece by piece.
■Tip As a general rule of thumb, storing images in a database works well as long as the images are not enormous (for example, more than 50 MB) and do not need to be frequently edited by other applications.
Displaying Binary Data
ASP.NET isn’t restricted to returning HTML content. In fact, you can use the Response.BinaryWrite() method to return raw bytes and completely bypass the web-page model.
The following page uses this technique with the pub_info table in the pubs database (another standard database that’s included with SQL Server). It retrieves the logo field, which contains binary image data. The page then writes this data directly to the page, as shown here:
protected void Page_Load(object sender, System.EventArgs e)
{
string connectionString = WebConfigurationManager.ConnectionStrings["Pubs"].ConnectionString;
SqlConnection con = new SqlConnection(connectionString); string SQL = "SELECT logo FROM pub_info WHERE pub_id='1389'"; SqlCommand cmd = new SqlCommand(SQL, con);
try
{
con.Open();
SqlDataReader r = cmd.ExecuteReader(); if (r.Read())
{
byte[] bytes = (byte[])r["logo"]; Response.BinaryWrite(bytes);
}
r.Close();
}
finally
{
con.Close();
}
}
Figure 10-22 shows the result. It doesn’t appear terribly impressive (the logo data isn’t that remarkable), but you could easily use the same technique with your own database, which can include much richer and larger images.
When you use BinaryWrite(), you are stepping away from the web-page model. If you add other controls to your web page, they won’t appear. Similarly, Response.Write() won’t have any effect, because you are no longer creating an HTML page. Instead, you’re retuning image data. You’ll see how to solve this problem and optimize this approach in the following sections.
C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S |
381 |
Figure 10-22. Displaying an image from a database
Reading Binary Data Efficiently
Binary data can easily grow to large sizes. However, if you’re dealing with a large image file, the example shown previously will demonstrate woefully poor performance. The problem is that it uses the DataReader, which loads a single record into memory at a time. This is better than the DataSet (which loads the entire result set into memory at once), but it still isn’t ideal if the field size is large.
There’s no good reason to load an entire 2 MB picture into memory at once. A much better idea would be to read it piece by piece and then write each chunk to the output stream using Response.BinaryWrite(). Fortunately, the DataReader has a sequential access feature that supports this design. To use sequential access, you simply need to supply the CommandBehavior.SequentialAccess value to the Command.ExecuteDataReader() method. Then you can move through the row one block at a time, using the DataReader.GetBytes() method.
When using sequential access, you need to keep a couple of limitations in mind. First, you must read the data as a forward-only stream. Once you’ve read a block of data, you automatically move ahead in the stream, and there’s no going back. Second, you must read the fields in the same order they are returned by your query. For example, if your query returns three columns, the third of which is a binary field, you must return the values of the first and second fields before accessing the binary data in the third field. If you access the third field first, you will not be able to access the first two fields.
Here’s how you would revise the earlier page to use sequential access:
protected void Page_Load(object sender, System.EventArgs e)
{
string connectionString = WebConfigurationManager.ConnectionStrings["Pubs"].ConnectionString;
SqlConnection con = new SqlConnection(connectionString); string SQL = "SELECT logo FROM pub_info WHERE pub_id='1389'"; SqlCommand cmd = new SqlCommand(SQL, con);
try
{
con.Open();
SqlDataReader r = cmd.ExecuteReader(CommandBehavior.SequentialAccess);
if (r.Read()) |
|
{ |
|
int bufferSize = 100; |
// Size of the buffer. |
byte[] bytes = new byte[bufferSize]; |
// The buffer of data. |
long bytesRead; |
// The number of bytes read. |
long readFrom = 0; |
// The starting index. |
382C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S
//Read the field 100 bytes at a time.
do
{
bytesRead = r.GetBytes(0, readFrom, bytes, 0, bufferSize);
Response.BinaryWrite(bytes); readFrom += bufferSize;
} while (bytesRead == bufferSize);
}
r.Close();
}
finally
{
con.Close();
}
}
The GetBytes() method returns a value that indicates the number of bytes retrieved. If you need to determine the total number of bytes in the field, you simply need to pass a null reference instead of a buffer when you call the GetBytes() method.
Integrating Images with Other Content
The Response.BinaryWrite() method creates a bit of a challenge if you want to integrate image data with other controls and HTML. That’s because when you use BinaryWrite() to return raw image data, you lose the ability to add any extra HTML content.
To attack this problem, you need to create another page that calls your image-generating code. The best way to do this is to replace your image-generating page with a dedicated HTTP handler that generates image output. This way, you save the overhead of the full ASP.NET web form model, which you aren’t using anyway. (Chapter 5 introduces HTTP handlers.)
Creating the HTTP handler you need is quite easy. You simply need to implement the IHttpHandler interface and implement the ProcessRequest() method. The HTTP handler will retrieve the ID of the record you want to display from the query string.
Here’s the complete HTTP handler code:
public class ImageFromDB : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
string connectionString = WebConfigurationManager.ConnectionStrings["Pubs"].ConnectionString;
// Get the ID for this request.
string id = context.Request.QueryString["id"];
if (id == null) throw new ApplicationException("Must specify ID.");
// Create a parameterized command for this record. SqlConnection con = new SqlConnection(connectionString); string SQL = "SELECT logo FROM pub_info WHERE pub_id=@ID"; SqlCommand cmd = new SqlCommand(SQL, con); cmd.Parameters.AddWithValue("@ID", id);
try
{
con.Open(); SqlDataReader r =
cmd.ExecuteReader(CommandBehavior.SequentialAccess);
C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S |
383 |
if (r.Read()) |
|
{ |
|
int bufferSize = 100; |
// Size of the buffer. |
byte[] bytes = new byte[bufferSize]; |
// The buffer. |
long bytesRead; |
// The # of bytes read. |
long readFrom = 0; |
// The starting index. |
// Read the field 100 bytes at a time. |
|
do |
|
{ |
|
bytesRead = r.GetBytes(0, readFrom, bytes, 0, bufferSize); context.Response.BinaryWrite(bytes);
readFrom += bufferSize;
} while (bytesRead == bufferSize);
}
r.Close();
}
finally
{
con.Close();
}
}
public bool IsReusable
{
get { return true; }
}
}
Once you’ve created the HTTP handler, you need to register it in the web.config file, as shown here:
<httpHandlers>
<add verb="GET" path="ImageFromDB.ashx" type="ImageFromDB" />
</httpHandlers>
Now you can retrieve the image data by requesting the HTTP handler URL, with the ID of the row that you want to retrieve. Here’s an example:
ImageFromDB.ashx?ID=1389
To show this image content in another page, you simply need to set the src attribute of an image to this URL, as shown here:
<img src="ImageFromDB.ashx?ID=1389"/>
Figure 10-23 shows a page with multiple controls and logo images. It uses the following ItemTemplate in a GridView:
<ItemTemplate>
<table border='1'><tr><td>
<img src='ImageFromDB.ashx?ID=<%# Eval("pub_id")%>'/> </td></tr></table>
<b><%# Eval("pub_name") %></b> <br />
<%# Eval("city") %>, <%# Eval("state") %>, <%# Eval("country") %>
384 C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S
<br /><br /> </ItemTemplate>
And it binds to this data source:
<asp:SqlDataSource ID="sourcePublishers" ConnectionString="<%$ ConnectionStrings:Pubs %>" SelectCommand="SELECT * FROM publishers" runat="server"/>
Figure 10-23. Displaying database images in ASP.NET web page
This current HTTP handler approach works well if you want to build a detail page with information about a single record. For example, you could show a list of publishers and then display the image for the appropriate publisher when the user makes a selection. However, this solution isn’t as efficient if you want to show image data for every publisher at once, such as in a list control. The approach still works, but it will be inefficient because it uses a separate request to the HTTP handler (and hence a separate database connection) to retrieve each image. You can solve this problem by creating an HTTP handler that checks for image data in the cache before retrieving it from the database. Before you bind the GridView, you would then perform a query that returns all the records with their image data and load each image into the cache.
C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S |
385 |
Detecting Concurrency Conflicts
As discussed in Chapter 8, if a web application allows multiple users to make changes, it’s quite possible for two or more edits to overlap. Depending on the way these edits overlap and the concurrency strategy you’re using (see the section “Concurrency Strategies” in Chapter 8 for more information), this could inadvertently result in committing stale values back to the database.
To prevent this problem, developers often use match-all or timestamp-based concurrency. The idea here is that the UPDATE statement must match every value from the original record, or the update won’t be allowed to continue. Here’s an example:
UPDATE Shippers SET CompanyName=@CompanyName, Phone=@Phone
WHERE ShipperID=@original_ShipperID AND CompanyName=@original_CompanyName
AND Phone=@original_Phone"
SQL Server uses the index on the ShipperID primary key to find the record and then compares the other fields to make sure it matches. Now the update can succeed only if the values in the record match what the user saw when making the changes.
■Note As indicated in Chapter 8, timestamps are a better way to handle this problem than by explicating matching every field. However, this example uses the match-all approach because it works with the existing Northwind database. Otherwise, you would need to add a new timestamp column.
The problem with a match-all concurrency strategy is that it can lead to failed edits. Namely, if the record has changed in between the time the user queried the record and applied the update, the update won’t succeed. In fact, the data-bound controls won’t even warn you of the problem; they’ll just execute the UPDATE statement without any effect, because this isn’t considered an error condition.
If you decide to use match-all concurrency, you’ll need to at least check for lost updates. You can do this by handling the ItemUpdated event of the appropriate control. There you can check the RowsAffected property of the EventArgs object. If this property is 0, no records were updated, which is almost always because another edit changed the record and the WHERE clause in the UPDATE statement couldn’t match anything. (Other errors, such as trying an update that fails because it violates a key constraint or tries to commit invalid data does result in an error being raised by the data source.)
Here’s an example that checks for a failed update in the DetailsView control and then informs the user of the problem:
protected void DetailsView1_ItemUpdated(object sender, DetailsViewUpdatedEventArgs e)
{
if (e.AffectedRows == 0)
{
lblStatus.Text = "A conflicting change has already been made to this " + " record by another user. No records were updated.";
}
}
Unfortunately, this doesn’t make for the most user-friendly web application. It’s particularly a problem if the record has several fields, or if the fields take detailed information, because these edits are simply discarded, forcing the user to start from scratch.
A better solution is to give the user a choice. Ideally, the page would show the current value of the record (taking any recent changes into account) and allow the user to apply the original edited values, cancel the update, or make additional refinements and then apply the update. It’s actually quite easy to build a page that provides these niceties.
386 C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S
First, start with a DetailsView that allows the user to edit individual records from the Shippers table in the Northwind database. (The Shippers table is fairly easy to use with match-all concurrency because it has only three fields. Larger tables work better with the equivalent timestampbased approach.)
Here’s an abbreviated definition of the DetailsView you need:
<asp:DetailsView ID="detailsEditing" runat="server" DataKeyNames="ShipperID" AllowPaging="True" AutoGenerateRows="False" DataSourceID="sourceShippers" OnItemUpdated="DetailsView1_ItemUpdated" ...> <Fields>
<asp:BoundField DataField="ShipperID" ReadOnly="True" /> <asp:BoundField DataField="CompanyName" /> <asp:BoundField DataField="Phone" />
<asp:CommandField ShowEditButton="True" /> </Fields>
...
</asp:DetailsView>
The data source control that’s bound to the DetailsView uses a match-all UPDATE expression to implement strict concurrency:
<asp:SqlDataSource ID="sourceShippers" runat="server" ConnectionString="<%$ ConnectionStrings:Northwind %>" SelectCommand="SELECT * FROM Shippers" UpdateCommand="UPDATE Shippers SET
CompanyName=@CompanyName, Phone=@Phone WHERE ShipperID=@original_ShipperID AND CompanyName=@original_CompanyName AND Phone=@original_Phone" ConflictDetection="CompareAllValues">
<UpdateParameters>
<asp:Parameter Name="CompanyName" /> <asp:Parameter Name="Phone" /> <asp:Parameter Name="original_ShipperID" /> <asp:Parameter Name="original_CompanyName" /> <asp:Parameter Name="original_Phone" />
</UpdateParameters>
</asp:SqlDataSource>
You’ll notice the SqlDataSource.ConflictDetection property is set to CompareAllValues, which ensures that the values from the original record are submitted as parameters (using the prefix defined by the OldValuesParameterFormatString property).
Most of the work takes place in response to the DetailsView.ItemUpdated event. Here, the code catches all failed updates and explicitly keeps the DetailsView in edit mode.
protected void DetailsView1_ItemUpdated(object sender, DetailsViewUpdatedEventArgs e)
{
if (e.AffectedRows == 0)
{
e.KeepInEditMode = true;
...
But the real trick is to rebind the data control. This way, all the original values in the DetailsView are reset to match the values in the database. That means the update can succeed (if the user tries to apply it again).
...
detailsEditing.DataBind();
...
C H A P T E R 1 0 ■ R I C H D ATA C O N T R O L S |
387 |
Rebinding the grid is the secret, but there’s still more to do. To maintain the values that the user is trying to apply, you need to manually copy them back into the newly bound data control. This is easy but a little tedious.
...
// Repopulate the DetailsView with the edit values. TextBox txt;
txt = (TextBox)detailsEditing.Rows[1].Cells[1].Controls[0]; txt.Text = (string)e.NewValues["CompanyName"];
txt = (TextBox)detailsEditing.Rows[2].Cells[1].Controls[0]; txt.Text = (string)e.NewValues["Phone"];
...
At this point, you have a data control that can detect a failed update, rebind itself, and reinsert the values the user’s trying to apply. That means if the user clicks Update a second time, the update will now succeed (assuming the record isn’t changed yet again by another user).
However, this still has one shortcoming. The user might not have enough information at this point to decide whether to apply the update. Most likely, they’ll want to know what changes were made before they overwrite them. One way to handle this problem is to list the current values in a label or another control. In this example, the code simply unhides a Panel control that contains another DetailsView:
...
ErrorPanel.Visible = true;
}
}
The error panel describes the problem with an informative error message and contains a second DetailsView that binds to the matching row to show the current value of the record in question.
<asp:Panel ID="ErrorPanel" runat="server" Visible="False" EnableViewState="False"> There is a newer version of this record in the database.<br />
The current record has the values shown below.<br /> <br />
<asp:DetailsView ID="detailsConflicting" runat="server" AutoGenerateRows="False" DataSourceID="sourceUpdateValues" ...> <Fields>
<asp:BoundField DataField="ShipperID" /> <asp:BoundField DataField="CompanyName" /> <asp:BoundField DataField="Phone" />
</Fields>
...
</asp:DetailsView> <br />
*Click <b>Update</b>to override these values with your changes.<br />
*Click <b>Cancel</b>to abandon your edit.</span> <asp:SqlDataSource ConnectionString="<%$ ConnectionStrings:Northwind %>" ID="sourceUpdateValues" runat="server"
SelectCommand="SELECT * FROM Shippers WHERE (ShipperID = @ShipperID)" OnSelecting="sourceUpdateValues_Selecting">
<SelectParameters>
<asp:ControlParameter ControlID="detailsEditing" Name="ShipperID" PropertyName="SelectedValue" Type="Int32" />
</SelectParameters>
</asp:SqlDataSource>
</asp:Panel>
