ADO.NET Data Binding
One of the most common applications of data binding is with ADO.NET data objects. This type of binding is easy, painless, and built on one of .NET's core standards.The basic principle is the same for binding collections of custom objects. However, instead of specifying a property name with the DisplayMember, you use it to indicate the database field name. The following example uses this technique to bind to the ModelName column in a DataTable. It uses information drawn from Microsoft's sample IBuySpy e-commerce Web application. The result is shown in Figure 9-7.
![](/image/library/english/10169_fig9-7.jpg)
Figure 9-7: Binding to a DataView
DataSet dsStore = new DataSet();
dsStore.ReadXmlSchema(Application.StartupPath + "\\store.xsd");
dsStore.ReadXml(Application.StartupPath + "\\store.xml");
lstName.DataSource = dsStore.Tables["Products"];
lstName.DisplayMember = "ModelName";
Note
The ADO.NET examples in this chapter read DataSets from XML, as this approach doesn't clutter the examples with data access logic, and doesn't require any special relational database product. The "data aware" control examples later in this chapter separate the data logic into a discrete class, which allows this code to be completely independent of the data-binding logic.In this example, the code appears to bind to a DataTable object, but it actually binds to the DataTable.DefaultView property. This property provides a DataView object that implements the required IList interface. For the most part, you can ignore this lower-level reality, unless you want to use the DataView object to customize the displayed data. For example, the code that follows doesn't change the actual information in the DataTable, but it does ensure that only a subset of it will be shown in the list control:
DataSet dsStore = new DataSet();
dsStore.ReadXmlSchema(Application.StartupPath + "\\store.xsd");
dsStore.ReadXml(Application.StartupPath + "\\store.xml");
dsStore.Tables["Products"].DefaultView.RowFilter = "UnitCost < 5";
lstName.DataSource = dsStore.Tables["Products"];
lstName.DisplayMember = "ModelName";
Figure 9-8 shows the filtered list.
![](/image/library/english/10169_fig9-8.jpg)
Figure 9-8: Binding to a filtered DataView
The DataView class provides other properties that allow you to implement sorting, and to specify whether the data-bound collection allows deletions, additions, and modifications (these properties don't apply to the list control, which never allows the modification of a bound data source). Taken together, these options provide an extra layer of indirection, which allows your code to be more flexible.Table 9-1 shows useful DataView members and a description of each.
Table 9-1: Useful DataView Members
MemberDescription
RowFilterA string that allows you to filter the results based on any field. This string works like a tiny snippet of SQL code, meaning that string values must be enclosed in single quotes, and you can use the operators like =, <, and >.
RowStateFilterA combination of the values from DataViewRowState enumeration. This allows you to display rows that have been scheduled for deletion in the DataSet, pending the next update (deleted rows are usually hidden).
SortAllows you to configure the sort order for the DataView. You can enter a combination of columns, separated by commas (as in "CategoryID, ModelName"). Append a space and the letters DESC after a column name to indicate descending (reverse) sort order.
TableThe DataTable object that contains the data used by this DataView.
Tip
As you might imagine, you can even create multiple DataView objects, allowing you to show data from the same underlying DataSet in multiple controls, but with different filtering or sorting options.You can also bind to a list control with the following syntax. The result is the same, but in this case the DataSet.DefaultViewManager is used for the data binding. This property contains a DataViewManager object for the entire DataSet. The DisplayMember property then specifies the appropriate table and field.
DataSet dsStore = new DataSet();
dsStore.ReadXmlSchema(Application.StartupPath + "\\store.xsd");
dsStore.ReadXml(Application.StartupPath + "\\store.xml");
lstName.DataSource = dsStore;
lstName.DisplayMember = "Products.ModelName";
Note
All the ADO.NET code examples can be found in the ADO.NET Binding project included with the online samples for this book.
Multiple Control Binding
The next example shows a more practical use of data binding. The information from a product record is displayed on a form using a combination of three labels and a drop-down list control. This list control allows navigation—when the user selects a different model name, the other data bound controls are updated automatically without requiring any code.Here's the complete code for this example:
public class MultipleControlBinding : System.Windows.Forms.Form
{
// (Windows designer code omitted.)
internal System.Windows.Forms.ComboBox cboModelName;
internal System.Windows.Forms.Label lblDescription;
internal System.Windows.Forms.Label lblUnitCost;
internal System.Windows.Forms.Label lblModelNumber;
private void MultipleControlBinding_Load(object sender, System.EventArgs e)
{
DataSet dsStore = new DataSet();
dsStore.ReadXmlSchema(Application.StartupPath + "\\store.xsd");
dsStore.ReadXml(Application.StartupPath + "\\store.xml");
cboModelName.DataSource = dsStore.Tables["Products"];
cboModelName.DisplayMember = "ModelName";
lblModelNumber.DataBindings.Add("Text",
dsStore.Tables["Products"], "ModelNumber");
lblUnitCost.DataBindings.Add("Text",
dsStore.Tables["Products"], "UnitCost");
lblDescription.DataBindings.Add("Text",
dsStore.Tables["Products"], "Description");
}
}
Figure 9-9 shows the resulting form.
![](/image/library/english/10169_fig9-9.jpg)
Figure 9-9: Multiple binding with ADO.NET
Tip
In this example, the list control is really a navigation control that allows the user to access any record. In some cases this isn't the behavior you want. For example, in a form that allows the user to create a new product record, you might want to bind to a list of options for the field, but you wouldn't want selections in this control to affect the other controls. In this case, you would copy the items out of the array and into the list control with the AddRange() method, as described earlier, and then use simple binding with the list control's SelectedIndex or Text property.
Updating with Data Binding
As was described earlier, you can perform basic binding with any IList data source. However, data sources that implement additional interfaces can gain some extra features. Four such interfaces are listed in Table 9-2.
Table 9-2: Interfaces Used with Data Binding
InterfaceDescription
IListAllows simple data binding to a collection of identical types. (For example, you cannot bind to an ArrayList with different types of objects in it.)
IBindingListProvides additional features for notification, both for when the list itself has changed (for example, the number of items in the list increases), as well as when the list items themselves change (for example, the third item in a list of customers has a change to its FirstName field).
IEditableObjectAllows permanent changes. For example, this allows a data-bound control to commit its changes back to the source DataSet. This implementation provides BeginEdit, EndEdit, and CancelEdit methods.
IDataErrorInfoAllows data sources to offer error information that a control can bind to. This information consists of two strings: the Error property, which returns general error message text (for example, "An error has occurred") and the Item property, which returns a string with a specific error message from the column (for example, "The value in the Cost column cannot be negative").
The DataView, DataViewManager, and DataRowView ADO.NET objects work together to implement all these interfaces. This means that when you bind to a DataSet, you acquire a much greater level of functionality. For example, if you modify the multiple control sample to use input controls, you will be able to make changes that permanently modify the DataSet. When you navigate to a changed record, you will see that its change persists. Furthermore, if multiple controls display the same data (for example, if you use a list control for navigation and allow the same field to be modified in a text box) they will all be updated with the new content when you browse back to the record. You can see this behavior with the product name field in the example that follows. The code is largely unchanged. The key difference is that the Label controls are replaced with TextBox controls. Figure 9-10 shows the corresponding form.
![](/image/library/english/10169_fig9-10.jpg)
Figure 9-10: An editable bound data source
DataSet dsStore = new DataSet();
dsStore.ReadXmlSchema(Application.StartupPath + "\\store.xsd");
dsStore.ReadXml(Application.StartupPath + "\\store.xml");
cboModelName.DataSource = dsStore.Tables["Products"];
cboModelName.DisplayMember = "ModelName";
txtModelName.DataBindings.Add("Text", dsStore.Tables["Products"], "ModelName");
txtModelNum.DataBindings.Add("Text", dsStore.Tables["Products"], "ModelNumber");
txtUnitCost.DataBindings.Add("Text", dsStore.Tables["Products"], "UnitCost");
txtDesc.DataBindings.Add("Text", dsStore.Tables["Products"], "Description");
Of course, changes made to the data set won't affect the original data source (whether it is a database or an XML file, as it is in this case). Remember, the DataSet is always disconnected by nature. To commit changes, you need to add something like an update button, which would then use the WriteXml() method (in your example), or the DataAdapter.Update() method (to update a relational database). But because this book only covers the user interface aspect of your code, I won't explore these options.
Formatting Data Before Binding
One limitation in your current example is that there is no way to handle data that needs to be formatted before it can be displayed. (Occasionally, you may have values that come out of a database in a less-than-professional looking state. For example, certain fields might use hard-coded numbers that are meaningless to the user, or use a confusing short form.) There's also no way to do the converse—take user supplied data, and convert it to a representation suitable for the appropriate field.Luckily, both tasks are easy provided you handle the Format and Parse events for the Binding object. Format gives you a chance to modify values as they exit the database (before they appear in a data bound control). Parse allows you to take a user-supplied value and modify it before it is committed to the data source. Figure 9-11 shows the process.
![](/image/library/english/10169_fig9-11.jpg)
Figure 9-11: Formatting data
Here's an example that works with the UnitCost variable. It formats the numeric (decimal) value as a currency string when it's requested for display in a text box. The reverse process ensures that the final committed value doesn't use the currency symbol. To connect this logic, you need to create the Binding object, register to receive its events, and then add it to the DataBindings collection. Notice that the following code adds a trick—it registers for the DataTable's ColumnChanged event. This way, you can verify what value is actually inserted into the DataSet.
DataSet dsStore = new DataSet();
dsStore.ReadXmlSchema(Application.StartupPath + "\\store.xsd");
dsStore.ReadXml(Application.StartupPath + "\\store.xml");
cboModelName.DataSource = dsStore.Tables["Products"];
cboModelName.DisplayMember = "ModelName";
// Create the binding.
Binding costBinding = new Binding("Text", dsStore.Tables["Products"],
"UnitCost");
// Connect the methods for formatting and parsing data.
costBinding.Format += new ConvertEventHandler(DecimalToCurrencyString);
costBinding.Parse += new ConvertEventHandler(CurrencyStringToDecimal);
// Add the binding.
txtUnitCost.DataBindings.Add(costBinding);
// Register an event handler for changes to the DataTable.
dsStore.Tables["Products"].ColumnChanged += new EventHandler (TableChanged);
The event-handling code for formatting simply returns the new converted value by setting the e.Value property.
private void DecimalToCurrencyString(object sender, ConvertEventArgs e)
{
if (e.DesiredType == typeof(string))
{
// Use the ToString method to format the value as currency ("c").
e.Value += ((decimal)e.Value).ToString("c");
}
}
private void CurrencyStringToDecimal(object sender, ConvertEventArgs e)
{
if (e.DesiredType == typeof(decimal))
{
// Convert the string back to decimal using the static Parse method.
e.Value = Decimal.Parse(e.Value.ToString(),
System.Globalization.NumberStyles.Currency, null);
}
}
The DataTable.ColumnChanged event handler is quite straightforward. It notes the changes by updating a label.
private void TableChanged(object sender, System.Data.DataColumnChangeEventArgs e)
{
lblStatus.Text = "Detected change. Column " + e.Column.ColumnName;
lblStatus.Text += " updated to " + e.ProposedValue.ToString() + ".";
}
Figure 9-12 shows the form after changing a value.
![](/image/library/english/10169_fig9-12.jpg)
Figure 9-12: Formatting numbers to strings
Note
Be warned, this approach can lead you to mingle too many database details into your code. A better approach is to handle the problem at the database level, if you can. For example, if you use a list of numeric constants, create a table in the database that maps the numbers to text descriptions. Then use a Join query when retrieving the data to get it in the form you need for your interface. Or, try to encapsulate the details as much as possible using a dedicated database resource class.
Advanced Conversions
You can use a similar technique to handle more interesting conversions. For example, you could convert a column value to an appropriate string representation, straighten out issues of case, or ensure the correct locale-specific format for dates and times. Here's one example that compares hard-coded integers from the database against an enumeration:
private void ConstantToString(object sender, ConvertEventArgs e)
{
if (e.DesiredType == getType(string))
{
switch (e.Value)
{
case ProjectStatus.NotStarted:
e.Value = "Project not started.";
break;
case ProjectStatus.InProgress:
e.Value = "Project in progress.";
break;
case ProjectStatus.Complete:
e.Value = "Project is complete.";
break;
}
}
}
Now let's look at an additional trick that's useful when storing records that link to pictures. When storing a record that incorporates a graphic, you have two options. You can store the image as binary information in the database (which is generally less flexible but more reliable), or you can store the filename, and ensure that the file exists in the appropriate project directory. The next example uses the Format event to convert a picture name to the required Image object.Unfortunately, data binding is always a two-way street, and if you implement a Format event handler, you need to create a corresponding Parse event handler to reverse your change. In our example, the Format event handler takes the filename, and inserts the corresponding picture into a PictureBox. In the event handler, the code needs to take the picture, change it to the appropriate filename string, and insert this into the DataTable. This bidirectional conversion is required even though the application doesn't offer any way for the user to choose a new picture file, and the content in the PictureBox can't be changed.To make matters more complicated, there's no way to convert an image object back to the filename, so we have to fall back on another trick: storing the actual filename in the control, for retrieval later.Here's the data binding code:
cboModelName.DataSource = dsStore.Tables["Products"];
cboModelName.DisplayMember = "ModelName";
Binding pictureBinding = new Binding("Image", dsStore.Tables["Products"],
"ProductImage");
pictureBinding.Format += new ConvertEventHandler(FileToImage);
pictureBinding.Parse += new ConvertEventHandler(ImageToFile);
picProduct.DataBindings.Add(pictureBinding);
And here is the formatting code (note that it requires the System.Drawing namespace to be imported):
private void FileToImage(object sender, ConvertEventArgs e)
{
if (e.DesiredType == typeof(Image))
{
// Store the filename.
picProduct.Tag = e.Value;
// Look up the corresponding file, and create an Image object.
e.Value = Image.FromFile(Application.StartupPath + "\\" + e.Value);
}
}
private void ImageToFile(object sender, ConvertEventArgs e)
{
if (e.DesiredType == typeof(string)) {
{
// Substitute the filename.
e.Value = picProduct.Tag;
}
}
This can only be considered a "conversion" in the loosest sense. What's really happening here is a file lookup. The process, however, is completely seamless. If you allow the user to dynamically choose a picture (maybe from a file or the clipboard), you could even create a corresponding Parse event handler that saves it to the appropriate directory with a unique name and then commits that name to the database.
![](/image/library/english/10169_fig9-13.jpg)
Figure 9-13: "Converting" file names to image objects
Tip
The Format and Parse methods can run any .NET code (they aren't limited to simple data format strings, as they are in the corresponding ASP.NET data binding process). This provides an invaluable extra layer of indirection, and using it is one of the keys to making data binding work.With it, you can transform raw data into the appropriate presentation content.
Row Validation and Changes
Now that you realize how easy it is to commit changes to a bound DataSet, you are probably wondering what you can do to restrict the user's update ability—making sure some fields are read-only and others are bound by specific rules. This validation can be performed in exactly the same way it always is—by handling events like KeyPress in a text box, or using the validation techniques described in Chapter 4. These controls behave exactly like ordinary .NET controls—the only difference is that their changes are stored in the DataSet as soon as the user navigates to another record.Resist the urge to enter error-handling code into the Parse event handler. This method is purely designed to convert a value before attempting to store it. Instead, you can handle ordinary DataTable events like ColumnChanging. Here is an example that uses the ColumnChanging event, and refuses to allow a change to the UnitCost column if the number is negative. Instead, it substitutes the existing value, effectively canceling the change.
private void TableChanging(object sender,
System.Data.DataColumnChangeEventArgs e)
{
if (e.ProposedValue < 0)
{
e.ProposedValue = e.Row[e.Column.ColumnName];
}
}
To use this code, you need to connect the event handler (typically at the same time you add the data binding):
dsStore.Tables["Products"].ColumnChanging += new
DataColumnChangeEventHandler(TableChanging);
This code is useful as a basic level of error protection, but it doesn't provide an easy way to notify the user about the error, because the user has more than likely already moved to another record. In some cases you may need to prevent the user from navigating to a new record after making invalid changes. To apply this logic, you need to take manual control of record navigation. This technique is explored in the next section.The interesting question in these multiple control examples is how the controls work in conjunction with each other. If you know a little about ADO.NET, you'll remember that the DataSet is always disconnected for the data source, and doesn't provide any cursor or bookmark to store a current position (unlike the traditional ADO Recordset). Similarly, ordinary classes like arrays and ArrayList collections certainly don't have this capability. So where does it come from? The next section considers what's really at work in data binding, and shows how you can interact with it programmatically.