Sharing the goodness...
Beth Massi is a Senior Program Manager on the Microsoft Visual Studio BizApps team who build the Visual Studio tools for Azure, Office, SharePoint as well as Visual Studio LightSwitch. Learn more about Beth.
More videos »
Last few posts I've been building a WPF client against ADO.NET Data Services, if you missed them:
Today I want to show a simple Excel client that queries our service and allows us to edit the customers in Northwind. I'm going to use Visual Studio 2008 (VSTO) to build a document-level customization against an Excel 2007 Workbook. We'll also build an Actions Pane that allows us to query and save customers to our ADO.NET data service. Excel's tabular data format lends itself well to CRUD database operations when we Add, Delete and Update rows in a Sheet. (NOTE: You cannot create Office solutions in Visual Studio Express editions.)
Setting Permissions on Customers
The first thing we need to do on our data service is allow access to the Customers entity set on our data model. In the first article I showed how to set up our service and permissions. For this example we'll be building upon the same data service we used for the WPF client which works with Product and Categories so I'll just need to add access to Customers:
Public Class Northwind Inherits DataService(Of NorthwindEntities) ' This method is called only once to initialize service-wide policies. Public Shared Sub InitializeService(ByVal config As IDataServiceConfiguration) config.SetEntitySetAccessRule("Products", EntitySetRights.All) config.SetEntitySetAccessRule("Categories", EntitySetRights.AllRead) config.SetEntitySetAccessRule("Customers", EntitySetRights.All) End Sub...
Creating an Excel Client
Next we'll add the Excel 2007 project to the same solution we've been working on to make things simple for debugging. However, it's important to note that when we want to deploy the Excel (or WPF) client we should place the project into it's own solution. (For more information on deploying Office solutions, read this article.) You would also need to deploy the ADO.NET Data Service just like any other WCF Service to an IIS server first before deploying any clients.
Right-click on the solution and Add --> New Project then select the Office --> 2007 category on the left and choose the Excel 2007 Workbook project.
Next add the service reference to the data service by right-clicking on the Excel project and selecting Add Service Reference. This is exactly how we added the service reference to our WPF client which generates the client proxy and entity types for us.
Tracking Changes on Customer Entities
Our actions pane is going to control how we query and save the customers. But instead of interacting with the service client proxy directly I'm going to create a class that inherits from a BindingList(Of Customer) so that we can also encapsulate the tracking of adds, updates and deletes of the customers that we bring down from the service. This class will also have a method to execute a customer query filtered by a specified country as well as a method to save changes. Finally we'll need to override some BindingList methods that will allow us to tell the data service client whether to add, update or delete as the customer entities are added to, updated or removed from the list.
Imports ExcelClient.NorthwindService Imports System.ComponentModel Public Class MyCustomerList Inherits BindingList(Of Customer) Private DataServiceContext As New NorthwindEntities(New Uri("http://localhost:1234/Northwind.svc")) Private _isAdding As Boolean Private _isSaving As Boolean Private _hasChanges As Boolean Public ReadOnly Property HasChanges() As Boolean Get Return _hasChanges End Get End Property ''' <summary> ''' Queries the customers from the ADO.NET Data Service ''' and returns the number of customers returned in the results ''' </summary> ''' <param name="country">The country to use in the query filter</param> ''' <returns>number of customers returned from the service</returns> ''' <remarks></remarks> Public Function FillCustomersByCountry(ByVal country As String) As Integer Dim i = 0 Dim customers = From c In DataServiceContext.Customers _ Where c.Country = country _ Order By c.CustomerID For Each c In customers Me.Add(c) i += 1 Next Return i End Function ''' <summary> ''' Saves the customer changes back to the service. ''' </summary> ''' <returns>true if save was successful, otherwise an exception is thrown</returns> ''' <remarks></remarks> Public Function SaveChanges() As Boolean Dim saved As Boolean Try _isSaving = True Me.DataServiceContext.SaveChanges() _hasChanges = False saved = True Finally _isSaving = False End Try Return saved End Function Protected Overrides Function AddNewCore() As Object _isAdding = True Return MyBase.AddNewCore() End Function Protected Overrides Sub RemoveItem(ByVal index As Integer) Dim c = Me(index) Me.DataServiceContext.DeleteObject(c) _hasChanges = True MyBase.RemoveItem(index) End Sub Protected Overrides Sub OnListChanged(ByVal e As System.ComponentModel.ListChangedEventArgs) If Not _isSaving Then 'The Customer partial class will need to implement ' INotifyPropertyChanged for this will work If e.ListChangedType = ListChangedType.ItemChanged Then If _isAdding Then Me.DataServiceContext.AddToCustomers(Me(e.NewIndex)) _isAdding = False Else Me.DataServiceContext.UpdateObject(Me(e.NewIndex)) End If _hasChanges = True End If MyBase.OnListChanged(e) End If End Sub End Class
In order for the OnListChanged event to fire when a property on a customer entity is changed we will need to extend the partial class generated on the client. We'll need to Implement INotifyPropertyChanged and overwrite some partial methods just like we did to our product entity in the last post when we added validation. It's important to make sure the partial class is in the same namespace as the client service reference.
Imports ExcelClient.NorthwindService Imports System.ComponentModel Namespace NorthwindService Partial Public Class Customer Implements INotifyPropertyChanged Public Event PropertyChanged(ByVal sender As Object, ByVal e As PropertyChangedEventArgs) _ Implements INotifyPropertyChanged.PropertyChanged Private Sub FirePropertyChanged(ByVal propertyName As String) If propertyName <> "" Then RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(propertyName)) End If End Sub Private Sub OnAddressChanged() FirePropertyChanged("Address") End Sub Private Sub OnCityChanged() FirePropertyChanged("City") End Sub Private Sub OnCompanyNameChanged() FirePropertyChanged("CompanyName") End Sub Private Sub OnContactNameChanged() FirePropertyChanged("ContactName") End Sub Private Sub OnContactTitleChanged() FirePropertyChanged("ContactTitle") End Sub Private Sub OnCountryChanged() FirePropertyChanged("Country") End Sub Private Sub OnCustomerIDChanged() FirePropertyChanged("CustomerID") End Sub Private Sub OnFaxChanged() FirePropertyChanged("Fax") End Sub Private Sub OnPhoneChanged() FirePropertyChanged("Phone") End Sub Private Sub OnPostalCodeChanged() FirePropertyChanged("PostalCode") End Sub Private Sub OnRegionChanged() FirePropertyChanged("Region") End Sub End Class End Namespace
Binding an Excel Sheet to the Customer List
Now that we have a way to track our customer entities we can work with them on an Excel Sheet by binding them to a ListObject control. First open up the data sources window (on the main menu select Data --> Show Data Sources) and then add a new data source and select the Object data source type. Then expand the ExcelClient.NorthwindService namespace and select the Customer object. Now open up Sheet1 in the project to open the Excel designer and drag the Customer onto the Sheet, this will create a ListObject control that is bound to a CustomerBindingSource.
Right-click on the sheet and View Code. All we need to do is set the CustomerBindingSource.DataSource property to an instance of our MyCustomerList. I'm going to make the list a public field of the Sheet so the Actions Pane can interact with it easily.
Public Class Sheet1 Public CustomerList As New MyCustomerList() Private Sub Sheet1_Startup() Handles Me.Startup Me.CustomerBindingSource.DataSource = Me.CustomerList End Sub End Class
Hooking Up the Actions Pane
In order to add an Actions Pane to an Excel Workbook we need to open up the Workbook code-behind so double-click on ThisWorkbook in the project and add the pane to the ActionPane.Controls collection like so:
Public Class ThisWorkbook Public CustomerActions As New CustomerActionsPane() Private Sub ThisWorkbook_Startup() Handles Me.Startup Me.ActionsPane.Controls.Add(Me.CustomerActions) End Sub End Class
Now back in the CustomerActionsPane we can write code to interact with the CustomerList on Sheet1. We'll just add some code to the click event handlers of our buttons. First the Find button click handler will attempt to grab a reference to the CustomerList on Sheet1 and then it will call the FillCustomersByCountry method we created:
Public Class CustomerActionsPane Private Sub cmdFind_Click() Handles cmdFind.Click Dim msg = "" Try Dim searchTerm = Me.txtSearchTerm.Text.Trim() If searchTerm <> "" Then Dim list = ExcelClient.Globals.Sheet1.CustomerList Dim count = list.FillCustomersByCountry(searchTerm) msg = String.Format("{0} customers returned from server.", count) Else msg = "Please enter a country filter." End If Catch ex As Exception msg = ex.ToString End Try Me.txtStatus.Text = msg End Sub...
Handling Exceptions from the Data Service
Next we need to write the code for our Save button click handler. Here we need to anticipate exceptions coming back from our database. By default we don't expose verbose exception details from our data service because this could give malicious hackers clues into the architecture of our service or worse our database structure. So what we get back from the ADO.NET data service is a DataServiceRequestException with a generic message. You can add additional information to this exception by overriding the HandleException method on the data service itself. For instance we could do something like this to return database exceptions:
Public Class Northwind Inherits DataService(Of NorthwindEntities) ' This method is called only once to initialize service-wide policies. Public Shared Sub InitializeService(...
Protected Overrides Sub HandleException(ByVal args As HandleExceptionArgs) If args.Exception.InnerException IsNot Nothing Then If TypeOf args.Exception.InnerException Is SqlClient.SqlException Then 'If we have config.UseVerboseErrors = True in the InitializeService ' then the InnerException (last parameter) will be serialized in detail ' including SQL message and full stack trace otherwise just the generic ' message below will be returned. args.Exception = New DataServiceException(args.ResponseStatusCode, _ TypeName(args.Exception.InnerException), _ "The database could not save the data. Check constraints.", _ "en-US", _ args.Exception.InnerException) End If End If 'TODO: Log the exception End Sub...
The message that is returned, however, is an XML document which contains different information depending on if we have config.UseVerboseErrors set to True in the InitializeService method or not. If we're not (the default) then the XML document returned from this handler would look like:
<?xml version="1.0" encoding="utf-8" standalone="yes"?> <error xmlns="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"> <code>SqlException</code> <message xml:lang="en-US"> The database could not save the data. Check constraints. </message> </error>
If we did have config.UseVerboseErrors = True in the InitializeService then the XML document would be:
<?xml version="1.0" encoding="utf-8" standalone="yes"?> <error xmlns="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"> <code>SqlException</code> <message xml:lang="en-US"> The database could not save the data. Check constraints. </message> <innererror xmlns="xmlns"> <message> The DELETE statement conflicted with the REFERENCE constraint "FK_Orders_Customers"... </message> <type>System.Data.SqlClient.SqlException</type> <stacktrace> at System.Data.SqlClient.SqlConnection.OnError(SqlException...full stack trace... </stacktrace> </innererror> </error>
It's definitely not a good idea to return database details in error messages so make sure your production code does not have UseVerboseErrors set to True.
So back on our actions pane we can hook up the Save button click handler and write a few lines of code to extract any message from the XML. First we need to add an XML namespace Import because the error document declares a default namespace as seen above.
Imports System.Data.Services.Client Imports <xmlns="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"> Public Class CustomerActionsPane Private Sub cmdFind_Click(... Private Sub cmdSave_Click() Handles cmdSave.Click Dim msg = "Customers could not be saved." & vbCrLf Try Dim list = ExcelClient.Globals.Sheet1.CustomerList If list.SaveChanges() Then msg = "Customers saved." End If Catch ex As DataServiceRequestException When ex.InnerException IsNot Nothing Try Dim xmlError = XElement.Parse(ex.InnerException.Message) msg &= xmlError.<message>.Value Catch ex1 As Exception 'message isn't xml, so display string msg &= ex.InnerException.ToString() End Try Catch ex As Exception msg &= ex.ToString End Try Me.txtStatus.Text = msg End Sub End Class
Running the Excel Client
Now that we've got all our code written let's open up Fiddler and SQL Profiler and run this baby. After the Actions Pane opens we can click the Find button and the Customer list is populated and we can see it on our Sheet. From here we can add a new row or modify rows and the changes will be tracked in our list. We can also delete rows as well by right-clicking on the row and selecting Delete. (click image to enlarge)
Slick! Note that we will trigger a constraint violation if we attempt to delete a Customer that has Orders and this will cause our ExceptionHandler to return a generic message that is displayed in the status message area. We're not doing it in this example but you can opt to continue processing the rest of the changes by indicating that option in the SaveChanges method. In that case you'll get a DataServiceRespons object back that you can inspect for errors on the offending submissions. I'll expand on that in a follow-up post.
I've added a new Code Gallery sample so you can download it and play with the Excel client. It also includes the WPF client we built in the previous posts.
Enjoy!
PingBack from http://blog.a-foton.ru/index.php/2009/01/23/adonet-data-services-building-an-excel-client/
I came across some interesting blog posts this week and here's a few links that I'd like to share with
Usare Excel come client di un DataService ADO.NET
The last week I've been working with a couple teammates on an Office Business Application (OBA) demonstration
The last week I've been working with a couple teammates on an Office Business Application (OBA) demonstration
Last post I talked about the high-level architecture of our O ffice B usiness A pplication for the new
Beth Massi has been busy creating a number of great posts describing how to build add-ins to Excel, Outlook,
OBA Part 1 - Exposing Line-of-Business Data Published 03 February 09 02:23 PM Last post I talked about
Is it possible to use your solution/code in VB Express Edition?
Sorry Carl, Office tools are only available in VS Pro and higher.
Thanks for this great post. Can you please provide information, on how to add data via excel. The example in this code, would save updates to existing records, but it doesn't save new changes that I add to the excel table.
Hi Beth,
I adopted your solution to use in Application-Level-AddIn. Working very nice. Excepted that I have to extend the object object via GetVstoObject, what works also almost nice.
Calling it from VSTO-AddIn works fine, calling it via COMAutomation (msdn.microsoft.com/.../bb608614.aspx) Works also fine if called out of Excel VBA.
Doing the same in VB6 or VB.Net2008 via Automation it's not working. GetVstoObject reutrn always "nothing". Any hints, I posted it also to the msdn Forum (social.msdn.microsoft.com/.../d6ccae3f-2d76-49a4-9450-4ca2923ef56d)
Thanks Bernhard