LINQ to SQL N-Tier Smart Client - Part 2 Building the Client

In my last post we built the service and data access layer for our LINQ to SQL N-Tier application. In this post we'll walk through building a very simple Windows client form that works with our middle-tier.

Adding the Service Reference

Now that we have our middle-tier built it's time to add the service reference to the client project. Sine we have both .NET on the server and the client I'm going to use type sharing so that we can reuse the business objects (LINQ to SQL classes) on both ends. If you recall we we already added a project reference on the client to the OMSDataLayer project that defines these types.

Once you add that project reference we can add the service reference by right-clicking on the client and selecting "Add Service Reference" which opens up the Visual Studio 2008 Add Service Reference dialog. Hit the Discover button and it will pick up the OMSService in our solution. Click on the "Advanced" button and you'll notice some interesting settings here that I should mention.

Note here that the default is to "Reuse types in all referenced assemblies". This means that since we added the project reference to our LINQ to SQL business objects first, when the service proxy is generated it will not create new classes on the client, instead it will reference our business object types directly. Although this can make versioning more of a challenge it drastically cuts down the amount of code we have to write to maintain our business rules because now they are shared. However note that rules we call from the client cannot access the database directly. Our application here does not have any rules like that but it's something you may need to code for in your scenarios.

The other interesting settings I'll mention are the Collection type and Dictionary collection type settings since we're passing these types from our service. You can set these types to serialize differently if you need to. For instance, you can set the collection type to a BindingList if you are going to use all the collections from this service in typical data binding scenarios. Since this setting is for the entire service and we're only going to need a BindingList for just our GetOrdersByCustomerID result, I'm opting to keep the default Array type instead.

Loading the Data

Now we're ready to build our n-tier master-detail (one-to-many) form. Create a new form and then add a new data source (Menu, Data --> Add New Data Source) and select Object. Then expand the OMSDataLayer and choose the Order object and then do it again for Product.

Now we can build the master-detail form like I showed in this post (see the "Data Sources and Data Binding the Form" section) but this time against the objects in the shared assembly. The other main difference is that we don't need the Customer object because we're going to limit our data to just one customer.

Now we're ready to create an instance of our service reference and load the Orders from the middle-tier. Since the list will deserialize as an array, I'm going to place them into a BindingList that the form will manage. This will give us automatic add/delete support to the collection and a better data binding experience. I'm also going to set up a couple lists to track deletes of Order and OrderDetails. In a real application typically you create your own subclass of the BindingList and have it track these things but I'm trying to keep this example simple. We'll also load the products just like we did before but this time in our query we call the service instead.

 Public Class NtierMasterDetailForm

    Dim customerID As Integer = 1 'should come from a search form

    Dim proxy As New OMSServiceReference.OMSServiceClient

    Dim Orders As New BindingList(Of Order)
    Dim DeletedOrders As New List(Of Order)
    Dim DeletedDetails As New List(Of OrderDetail)

    Private Sub Form1_Load() Handles MyBase.Load

        'Load the orders from our service
        Dim orderList = proxy.GetOrdersByCustomerID(customerID)

        For Each o In orderList
            Me.Orders.Add(o)
        Next

        Me.OrderBindingSource.DataSource = Me.Orders

        Dim emptyProduct As Product() = _
                {New Product With {.Name = "<Select a product>", .ProductID = 0}}

        Me.ProductBindingSource.DataSource = (From Empty In emptyProduct).Union( _
                                              From Product In proxy.GetProductList _
                                              Order By Product.Name)
    End Sub

Tracking Changes on the Objects

Now let's see how we're going to track all the changes made to the Orders and OrderDetails. First let's take another look at our BaseBusiness class. This is the class that we created in this post when we implemented our validation. When we built the middle-tier I mentioned that we needed to add this property but it's the client that needs to set it. Here's a look at the modifications we need to make to the BaseBusiness object including adding the DataMember attribute to the new IsDirty property as well as on the ValidationErrors dictionary.

 <DataContract()> _
Public Class BaseBusiness
    Implements IDataErrorInfo

    Private m_isDirty As Boolean
    <DataMember()> _
    Public Property IsDirty() As Boolean
        Get
            Return m_isDirty
        End Get
        Set(ByVal value As Boolean)
            m_isDirty = value
        End Set
    End Property

    'This dictionary contains a list of our validation errors for each field
    Private m_validationErrors As New Dictionary(Of String, String)

    <DataMember()> _
    Public Property ValidationErrors() As Dictionary(Of String, String)
        Get
            Return m_validationErrors
        End Get
        Set(ByVal value As Dictionary(Of String, String))
            m_validationErrors = value
        End Set
    End Property
.
.
.

Since LINQ to SQL classes implement IPropertyNotifyChanged we can handle this event to set the IsDirty flag. The easiest way to set this flag is to tell the business objects themselves to do it. In order to hook up this event handler again when the objects are deserialized from the WCF service we can attribute a method with the OnDeserializedAttribute and add an event handler to the PropertyChanged event on all our business objects.

 Partial Class Order
    Inherits BaseBusiness

    <OnDeserialized()> _
    Private Sub OnDeserialized(ByVal context As StreamingContext)
        AddHandler Me.PropertyChanged, AddressOf MyPropertyChanged
    End Sub

    Private Sub MyPropertyChanged(ByVal sender As Object, 
                                  ByVal e As System.ComponentModel.PropertyChangedEventArgs) _
                                  Handles Me.PropertyChanged
        If e.PropertyName <> "Customer" Then
            Me.IsDirty = True
        End If
    End Sub
.
.
. 

The trick in the handler is to set the IsDirty flag only if the entity reference (the parent reference) property is not being set because we want to only set this flag if the user is making changes, not when the collection reference is set by the system.

Tracking adds is really easy because when an object is added to the collection it will be sent to the middle-tier and we can use the primary keys to determine if the Order or OrderDetail is new. For instance, if the OrderID on the Order is equal to zero (OrderID = 0) then we know we have a new object in the collection.

Deletes are a bit trickier because when you delete an object from the collection it's gone. If you are implementing a custom BindingList then you can just override the RemoveItem method but in our simple form we're just going to add the Order or OrderDetail being deleted to our Deleted* lists when the delete buttons are clicked on the form.

 Private Sub OrderNavigatorDeleteItem_Click() Handles BindingNavigatorDeleteItem.Click
    'Track deletes of orders
    If Me.OrderBindingSource.Position > -1 Then
        Dim order As Order = CType(Me.OrderBindingSource.Current, Order)
        If order.OrderID > 0 Then
            'Greater than 0 indicates that the object came from the database.
            'If it's = 0 then we know the object was added here then deleted 
            '  and we don't need to track that.
            Me.DeletedOrders.Add(order)
        End If
    End If
End Sub

Private Sub DetailNavigatorDeleteItem_Click() Handles DetailNavigatorDeleteItem.Click
    'Track deletes of details
    If Me.OrderDetailsBindingSource.Position > -1 Then
        Dim detail As OrderDetail = CType(Me.OrderDetailsBindingSource.Current, OrderDetail)
        If detail.OrderDetailID > 0 Then
            Me.DeletedDetails.Add(detail)
        End If
    End If
End Sub

Validating and Saving our Changes

Before we send the changes to the service on the middle-tier we should validate the business objects here to save a round-trip. When we were working with the LINQ to SQL DataContext in connected mode the objects were validated when we called SubmitChanges(). This still happens in our middle-tier code but we need to validate here on the client as well so I added a public Validate method to the LINQ to SQL partial classes that just simply call into the OnValidate private methods we wrote previously. In the case of Order we'll also validate any OrderDetails.

 Partial Class Order
    Inherits BaseBusiness
    .
    .
    . 
    Public Sub Validate()
        Me.OnValidate(System.Data.Linq.ChangeAction.None)

        'Validate the OrderDetails if there are any
        For Each d In Me.OrderDetails
            d.Validate()
        Next
    End Sub
.
.
.

Now we're ready to write our save code. If everything validates here on the client we first then send the deletes to the middle-tier, and if all goes well there then we clear the lists where we were tracking those objects. Then we can send the added and updated rows into the middle-tier. The middle-tier will then perform the validation there and then update and insert the business objects, and return the added primary/foreign keys. If we had any additional middle-tier business rules then those would also run and we could add additional validation messages that would be sent back in the ValidationErrors collection on each object.

The last thing left to do is dump the collection coming back from the middle-tier with our added keys back into the BindingList on our form. We just need to suspend the data binding first then we can copy the array back into the BindingList collection. Here's all the save code and supporting form methods.

 Private Sub OrderBindingNavigatorSaveItem_Click() _
    Handles OrderBindingNavigatorSaveItem.Click

    Me.Save()
End Sub

''' <summary>
''' Saves all changes to the middle-tier
''' </summary>
''' <remarks></remarks>
Private Sub Save()
    'Push any pending edits on the BindingSources to the BindingList
    Me.Validate()
    Me.OrderBindingSource.EndEdit()
    Me.OrderDetailsBindingSource.EndEdit()

    Dim saved = True

    'Only save changes if there are some and they are valid
    If Me.HasChanges AndAlso Me.ValidateOrders() Then

        Dim saveOrders = Me.Orders.ToArray

        Try
            If Me.DeletedDetails.Count > 0 OrElse Me.DeletedOrders.Count > 0 Then
                'Delete any orders/details
                If proxy.DeleteOrders(Me.DeletedOrders.ToArray, _
                                      Me.DeletedDetails.ToArray) Then

                    Me.DeletedDetails.Clear()
                    Me.DeletedOrders.Clear()
                Else
                    saved = False
                End If
            End If

            If saved Then
                If saveOrders.Length > 0 Then
                    'Update/insert orders/details
                    saved = proxy.SaveOrders(saveOrders)
                End If
            End If

        Catch ex As Exception
            MsgBox(ex.ToString)
        End Try

        'merges added keys and any validation errors from the middle-tier
        Me.MergeOrdersList(saveOrders)
    End If

    If Me.HasErrors Then
        'Display any errors if there are any (same technique as before)
        Me.DisplayErrors()
        MsgBox("Please correct the errors on this form.")
    Else
        If saved Then
            MsgBox("Your data was saved.")
        Else
            MsgBox("Your data was not saved.")
        End If
    End If
End Sub

''' <summary>
''' Returns True if there are any validation errors on the business objects.
''' </summary>
''' <returns></returns>
''' <remarks></remarks>
Private Function HasErrors() As Boolean
    For Each o In Me.Orders
        If o.HasErrors Then Return True
    Next
    Return False
End Function

''' <summary>
''' Validates all the orders (order details are validated in the Order.Validate)
''' </summary>
''' <remarks></remarks>
Private Function ValidateOrders() As Boolean
    Try
        For Each o In Me.Orders
            o.Validate()
        Next
    Catch ex As ValidationException
        Return False
    End Try
    Return True
End Function

''' <summary>
''' Returns True if there are any changes to any of the orders/details.
''' </summary>
''' <returns></returns>
''' <remarks></remarks>
Private Function HasChanges() As Boolean
    If Me.DeletedDetails.Count > 0 OrElse Me.DeletedOrders.Count > 0 Then
        Return True
    End If
    For Each o In Me.Orders
        If o.IsDirty Then Return True
        For Each d In o.OrderDetails
            If d.IsDirty Then Return True
        Next
    Next
    Return False
End Function

''' <summary>
''' Copies from array to the BindingList while suspending data binding
''' </summary>
''' <param name="changes"></param>
''' <remarks></remarks>
Private Sub MergeOrdersList(ByVal changes() As Order)
    Dim pos = Me.OrderBindingSource.Position
    Me.OrderBindingSource.SuspendBinding()
    Me.OrderBindingSource.RaiseListChangedEvents = False

    Me.Orders.Clear()
    For Each o In changes
        Me.Orders.Add(o)
    Next

    Me.OrderBindingSource.ResumeBinding()
    Me.OrderBindingSource.RaiseListChangedEvents = True
    Me.OrderBindingSource.Position = pos
End Sub

And that's basically it. As you can see even in it's simplest implementation (that I could think of) writing n-tier applications with LINQ to SQL takes some work, especially as the relations between our object collections increase. LINQ to SQL is really just used as the data access technology in the middle-tier, everything on top of that is up to us to implement as we see fit for our particular scenarios.

You can download the sample application on CodeGallery here.

Enjoy!