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

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

  • Comments 14

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!

Leave a Comment
  • Please add 3 and 1 and type the answer here:
  • Post
  • Continuing my Microsoft LINQ alerts... This time LINQ to SQL for VB developers. I'm a big fan of the

  • In my previous posts this week I showed how to build a simple distributed application with a Windows

  • This is a 3 part post where Beth Massi, MSDN Community Program Manager for Visual Basic, constructs an

  • This sample shows you one way to build an n-tier application using LINQ to SQL classes and a disconnected DataContext.

  • Hi Beth,

    I am an .NET evangelist. I have followed you video on Updating Related Tables and taken all the steps to maintain foreign key constraints. When I enter new row in parent and then try to enter new child data even though I have made sure to call end_edit function on parent to flush the data, I am getting error while clicking saveitem button from the menu. The Error is as "A foreign key value cannot be inserted because a corresponding primary key value does not exist vb.net"

    Please send me pointers to fix this error at arunagrahri@gmail.com

    Thanks and Regards,

    Arun

  • At the end of June I run workshops with 2 ISVs both looking to completely re-develop their applications.

  • At the end of June I run workshops with 2 ISVs both looking to completely re-develop their applications.

  • Hi Beth, thanks for your great article. I was inspired to use this approach for my newest project.

    I did notice something a little strange.. on my classes when I bind them to a master detail form similar to yours and the user clicks the delete button on the binding navigator, the event fires AFTER the item is removed. On your form the event fires BEFORE the item is removed. Any ideas why this might happen?

    I got around it by handling the delete myself instead of letting the control do it and it won't be an issue when I make my own bindinglist class but I am curious.

  • One of the things I was really missing from WPF when I started to dig into data binding was feature consistency

  • Hi Greg,

    I just set the DeleteItem property of the BindingNavigator to "none" and then handle the click event myself.

    HTH,

    -B

  • Thanks Beth.. this approach is really making me look like a hero on this project everything is working quite well.

    I ran into one more small issue.

    Everything works great if do like your example but when I try to create the main data object on the client instead of the server (imagine making a new order when the database is empty) the child objects don't seem to make it to the server. If the main object is created on the server and sent to the client like your example I can add child objects to it ( and children of the children even) and everything serializes back to the server OK. I can probably get around it by adding a 'createblank' type server method but I was wondering if you knew why this might be happening.

  • Nevermind, your example seems to work in this case I must have introduced a bug

  • [原文作者]: Beth Massi [原文链接]: Better Data Editing Features in WPF with SP1 当我开始钻研数据绑定时,在 WPF 中我最想念的一项功能

  • Hi Beth,

    I have a problem with the demo code ... new orders/details are never saved because the "IsDirty" property is never set. I've been following you're code and set the DeSerializeation stuff as next (order-class):

    [OnDeserialized()]

           private void OnDeserialized(StreamingContext context)

           {

               this.PropertyChanged += new System.ComponentModel.PropertyChangedEventHandler(Order_PropertyChanged);

           }

           void Order_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)

           {

               if (e.PropertyName != "Customer")

               {

                   this.IsDirty = true;

               }

           }

    But this code never gets fired !

    any clue ?

    thx,

    Emmanuel.

Page 1 of 1 (14 items)