Beyond DataBinder in ASP.NET Using VB.NET

Introduction

The DataBinder.Eval method is a very handy way to achieve data binding in Web Forms applications. Because it uses reflection, it is totally generic in the types of data source that it can handle. Of course this versatility comes at a price. The performance implications of reflection are often remarked upon. However even more important to my mind is the inability to handle situations where the data item doesn't exist or to gain access to ancillary information such as validation error messages.

If we are willing to restrict ourselves to the use of DataSets as our data source, we can enhance the functionality considerably allowing us to produce rich UIs for handling validation errors such as the example below.

In this article, I'm assuming you have at least played with the Web Forms designer in Visual Studio and are familiar with Web Forms binding expressions.

Non existence of Data

It is not uncommon in a UI to have a situation where the data being bound-to does not exist. One example would be the picking of a customer for an invoice where the system might automatically fill in various controls with the address of that customer. Before the customer has been picked, there would be no data for these controls to be bound to. Unfortunately the standard DataBinder.Eval function will generate an exception in such a situation.
There is a slightly more contrived example in the screen grab below where we are entering a set of Staff Information records. Initially there are no records so we would expect all the data entry controls to be either read-only or disabled.


Take for example the "Date Of Birth" TextBox. It has a Binding that ensures ReadOnly is set whenever no row exists for the data that it intends to display. This is done with a call to DataSetBinder.RowExists.

<asp:textbox id=TextBox2 style="Z-INDEX: 106; LEFT: 144px; POSITION: absolute; TOP: 160px"
runat="server"
Text='<%# DataSetBinder.Eval(WebForm1_Staff1,"Staff.DateOfBirth","{0:d}") %>'
ReadOnly='<%# Not DataSetBinder.RowExists(WebForm1_Staff1,"Staff") %>'
ToolTip='<%# DataSetBinder.GetError(WebForm1_Staff1,"Staff.DateOfBirth") %>'
CssClass='<%# IIf(DataSetBinder.HasError(WebForm1_Staff1,"Staff.DateOfBirth"),"error","")%>'>
</asp:textbox>

The binding of the Text property using the DataSetBinder.Eval method is very similar to what would be done with DataBinder with even the paramaters being the same. However DataSetBinder.Eval return the DefaultValue for any data that doesn't exist instead of throwing an exception. It obtains this from the DataSet which in turn derives it from the dataset schema. (The Binding of ToolTip and CssClass is used for validation which is discussed below.)

The first parameter to Eval is the data source from which to start, normally the DataSet, although it could be "Container" if the control is inside a template. The second parameter uses a syntax similar to the way data is addressed in a Windows Forms Binding. This parameter normally starts with a table name followed by a series of child relationship names if any and followed finally by the column name. However in the case where the control is inside a template, it starts as you might expect with "DataItem".

Binding Expression for a Top Level Control:
Eval(DataSet,"{TableName} [.{RelationName}] .{ColumnName}")

Binding Expression for a Control inside a template:
Eval(Container,"DataItem [.{RelationName}] .{ColumnName}")
 

The parameters for RowExists are exactly the same as Eval above. However the final .{ColumnName} is omitted from the second parameter, because the whole row is being specified rather than a column within it.

As well as making data entry control read-only or disabled, buttons should be disabled in cases where they require a row of data to exist. For example the Add button for a master Staff Information record should obviously be enabled always, whereas the Add button for a Training course detail should not be enabled unless there is a master record to attach it to. As you might expect this is handled by binding the Enabled property using another call to DataSetBinder.RowExists.

<asp:button id=Button10 style="Z-INDEX: 111; LEFT: 680px; POSITION: absolute; TOP: 272px"
runat="server"
Enabled='<%# DataSetBinder.RowExists(WebForm1_Staff1,"Staff")%>'
CommandName="Add" Text="Add">
</asp:button>
 

When the user presses the Add button to add the first Staff Information record, you can see how the various controls update their Enabled or ReadOnly states in the screen grab below.

Validation

In real world applications, validation can often be quite complex. It may involve checking elaborate relationships between the data entered or looking up additional data not available on the client. For this reason, while it's worth doing some simple sanity checking on the client, I'm a big fan of doing the bulk of the validation when the attempt is made to save the users data to the data tier. ADO.NET has a mechanism for recording errors against specific columns in specific rows, so this is the obvious way to return validation to the client.

Below is a sample piece of validation for an instance of Staff Information. It does some of the usual things like checking for required data. However it also checks for overlaps in the dates of courses- an example of the more complex validation that is often neccessary.

' Return True if the two Date Ranges overlap
 

Private Function RangesOverlap(ByVal s1 As Date, ByVal e1 As Date, ByVal s2 As Date, ByVal e2 As Date)
If Date.Compare(s1, e2) <= 0 And Date.Compare(e1, s2) >= 0 Then
Return
True
End
If
Return
False
End
Function
' This function is an example of the sort of constraints that might be applied
' to data prior to it being commited to the database
Private Function ValidateData() As System.Boolean
Dim dtStaff As DataTable = Me.WebForm1_Staff1.Tables("Staff")
Dim dvStaff As DataView = dtStaff.DefaultView()
For Each drv As DataRowView In dvStaff
drv.Row.ClearErrors()
If TypeOf drv("Name") Is DBNull Then
drv.Row.SetColumnError("Name", "Name is Required")
End If
If
TypeOf drv("DateOfBirth") Is DBNull Then
drv.Row.SetColumnError("DateOfBirth", "Date Of Birth is Required")
ElseIf (System.DateTime.Today().Subtract(CType(drv("DateOfBirth"), System.DateTime)).Days / 365 <= 15) Then
drv.Row.SetColumnError("DateOfBirth", "Staff must be over 15 in age")
End
If
Dim
dvTrainingCoursesTaken As DataView =drv.CreateChildView("Staff_TrainingCoursesTaken")
Dim startDate(dvTrainingCoursesTaken.Count) As Date
Dim
endDate(dvTrainingCoursesTaken.Count) As Date
Dim
row(dvTrainingCoursesTaken.Count) As System.Data.DataRow
Dim i As Int16 = 0
For Each drv2 As DataRowView In dvTrainingCoursesTaken
Dim HasStartDate, HasEndDate As System.Boolean
If TypeOf drv2("CourseName") Is DBNull Then
drv2.Row.SetColumnError("CourseName", "Course Name is Required")
End If
If
TypeOf drv2("StartDate") Is DBNull Then
drv2.Row.SetColumnError("StartDate", "Start Date is Required")
Else
startDate(i) = CType(drv2("StartDate"), DateTime).Date
HasStartDate =
True
End If
If
TypeOf drv2("EndDate") Is DBNull Then
drv2.Row.SetColumnError("EndDate", "End Date is Required")
Else
endDate(i) = CType(drv2("EndDate"), DateTime).Date
HasEndDate =
True
End If
If
(HasStartDate And HasEndDate) Then
row(i) = drv2.Row
End If
i = i + 1
Next
For i = 0 To dvTrainingCoursesTaken.Count
For j As Int16 = i + 1 To dvTrainingCoursesTaken.Count
If Not row(i) Is Nothing And Not row(j) Is Nothing And
RangesOverlap(startDate(i), endDate(i), startDate(j), endDate(j))
Then
row(i).SetColumnError("StartDate", "Course Dates cannot overlap")
row(i).SetColumnError("EndDate", "Course Dates cannot overlap")
row(j).SetColumnError("StartDate", "Course Dates cannot overlap")
row(j).SetColumnError("EndDate", "Course Dates cannot overlap")
End If
Next
Next
Next
Return Me.WebForm1_Staff1.HasErrors
End Function

It is very important for a good client experience to return all validation errors together. However it is not enough to return all the errors in one big bunch for the user to wade through. For ease of use it is important to associate the error message with the control containing the data in error. In complex UIs displaying error messages against each control can be messy and confusing. One obvious solution to this is the highlight the control in error and show a tool tip with the error message when the user moves the mouse across the control.

This can be seen in the screen grab below with the "Course Dates cannot overlap" error message. (By the way, in case there's any confusion when looking at this, my local date format is dd/mm/yyyy rather than month first.)

To achieve this result, first we need to bind the ToolTip property for each control to an expression involving the GetError method. This method has the same parameters as the Eval method, but it returns the associated error message rather than the data. It can be seen in two forms below. Firstly we have an example of the top-level "Date Of Birth" TextBox where the first parameter to DataSetBinder.GetError is the DataSet and the second parameter is a dot delimited path that starts with the TableName and finishes with ColumnName. (In this case we are specifying a column in the master table so no interveening relationship names are required.)

<asp:textbox id=TextBox2 style="Z-INDEX: 106; LEFT: 144px; POSITION: absolute; TOP: 160px"
runat="server"
Text='<%# DataSetBinder.Eval(WebForm1_Staff1,"Staff.DateOfBirth","{0:d}") %>'
ReadOnly='<%# Not DataSetBinder.RowExists(WebForm1_Staff1,"Staff") %>'
ToolTip='<%# DataSetBinder.GetError(WebForm1_Staff1,"Staff.DateOfBirth") %>'
CssClass='<%# IIf(DataSetBinder.HasError(WebForm1_Staff1,"Staff.DateOfBirth"),"error","")%>'>
</asp:textbox>
 

Secondly, here's an example of the "Course Start Date" which is included in a template. Here (just like Eval), the first parameter is Container and the second parameter starts with "DataItem." and finishes with the ColumnName. (Again we don't need interveening relationship names in this particular example.)

<asp:TextBox id=TextBox5 runat="server" Width="130px"
Text='<%# DataSetBinder.Eval(Container,"DataItem.StartDate","{0:d}") %>'
ToolTip='<%# DataSetBinder.GetError(Container,"DataItem.StartDate") %>'
CssClass='<%# IIf(DataSetBinder.HasError(Container,"DataItem.StartDate"),"error","")%>'>
</asp:textbox>
 

(As an aside notice that the ReadOnly property was not bound for this TextBox. This is because the row will always exist in this case - otherwise the line would not be generated by the DataList.)
Of course the user won't know which controls have an error message tool tip unless we provide visual feedback. This is done by binding the CssClass property to an expression involving the HasError method. As we flag a validation error by setting the style of the apppropriate control to "error" style, appropriate attributes must be set up for this style in our stylesheet, for example:

.error { background-color:#FFC080; border:1px solid }

Overview of DataSetBinder

To achieve all the functionality described above, DataSetBinder only needs a handfull of functions as shown below.

' Return the value of the specified item or the appropriate default if this item doesn't exist
Public Shared Function Eval(ByVal ds As System.Object, ByVal dataMember As System.String) As System.Object
' Return a formatted value of the specified item or the appropriate default
' if this item doesn't exist
Public Shared Function Eval(ByVal ds As System.Object, ByVal dataMember As System.String,ByVal format As System.String) As System.Object
' Return True if the specified Row exists
Public Shared Function RowExists(ByVal ds As System.Object, ByVal dataMember As System.String) As System.Boolean
' Return True if the specified item has an error associated with it
Public Shared Function HasError(ByVal ds As System.Object, ByVal dataMember As System.String) As System.Boolean
' Return the Error Message associated with an item if any
Public Shared Function GetError(ByVal ds As System.Object, ByVal dataMember As System.String) As System.String
' Return True if the specified row has any errors associated with it
Public Shared Function RowHasErrors(ByVal ds As System.Object, ByVal dataMember As System.String) As System.Boolean

Just like DataBinder, it has two versions of Eval- one for unformatted and one for formatted data. As I mentioned these versions don't employ reflection and return default values for situations where data does not exist.
The RowExists method is handy in binding expressions for ReadOnly or Enabled to prevent data input or inappropriate button clicking in situations where a row does not exist to operate on.

Rich feedback on validation failures can be achieved by binding CssStyle and ToolTip using the HasError and GetError methods respectively.

Finally I have included a RowHasErrors method that is not illustated in the example but could be used to flag the lines in a DataList that have validation failures in situations where not all the data is displayed in the line.
I could describe the implementation of these functions in detail but it probably simplest if you download the source and look at it directly. There is less than 150 lines including comments. What a nice surprise it is to achieve so much with so little.

Points of Interest

A demonstration project for the full Staff Information example used in this article. To keep things simple, this example simulates a data tier in an XmlDocument where in reality Microsoft SQLServer or something similar would be employed. It buffers all user input in the ViewState to prevent potentially costly uneccessary trips to the database. All the user input is validated and committed in one go when the Save button is pressed.

If you want to go straight through to using DataSetBinder, you only have to download the source for it and include it in your own project. You are granted unrestricted rights to use this code however you want.

The code released with this article is based on a portion of the AgileStudio product, which extends Visual Studio. Interestingly it took me half an hour to put together the Staff Information example in AgileStudio and a day and a half to add all the extra code to make it standalone.

Check out the free evaluation at
www.sekos.com which automatically maintains the bindings, datasets and SQL StoreProcs required for a specific user interface (for Windows or Web applications).

Conclusion

If you would like to see a version of DataSetBinder for CSharp, please let me know. I would also like to further explore UIs for robust data validation in a Web Forms environmen