ADO.NET Data Services - Intercepting Queries and Adding Validation

ADO.NET Data Services - Intercepting Queries and Adding Validation

  • Comments 9

Last few posts I've been building a WPF client against ADO.NET Data Services, if you missed them:

Today I want to show you how we can add validation or any other extra processing when data is queried from the data service as well as when we attempt to make changes to the data.

Ways of Querying a ADO.NET Data Service

First let's recap how we can query a data service. In our WPF client we've been taking advantage of LINQ but that's not the only way to send queries to the service. Because we're using the data service client framework we can write LINQ queries against the service and the client handles translating the queries into HTTP GETs. But we could also easily specify the raw URI's to send to the service. For instance, when we want to fill a combobox of categories ordered by CategoryName we could do this instead:

Imports System.Data.Services.Client
.
.
'Use the untyped DataServiceContext and pass URIs. Dim ctx As New DataServiceContext(New Uri("http://localhost:1234/Northwind.svc")) 'Explicitly execute the the HTTP GET Dim cats = ctx.Execute(Of Category)(New Uri("Categories?$orderby=CategoryName", UriKind.Relative)) 'Display results in a combobox Me.cboCategoryLookup.ItemsSource = cats.ToList()

In the code above the query is explicitly executed on the second line. You can achieve the same response by typing http://localhost:1234/Northwind.svc/Categories?$orderby=CategoryName in the address bar of your browser.

However one of the benefits of adding a service reference to our client is that it generates a proxy that inherits from the DataServiceContext that allows typed access to the entity sets that we're exposing from our service. This makes our code cleaner so we can write a LINQ query to do the same job instead:

Imports WpfClient.NorthwindService
.
.
Dim ctx As New NorthwindEntities(New Uri("http://localhost:1234/Northwind.svc")) 'Use LINQ to query the service instead Dim cats = From c In ctx.Categories _ Order By c.CategoryName 'Display results in a combobox. 'The query is executed when we access the results (calling ToList()) Me.cboCategoryLookup.ItemsSource = cats.ToList()

Notice however that there is a subtle difference in this code that you should be aware of. When you write a LINQ query it never executes unless you access the results. In this case we're accessing the results when we call ToList() on the query. This is called deferred execution and it's something to be aware of. But if we look in Fiddler then we can see that the exact same HTTP GET is sent to the service: http://localhost:1234/Northwind.svc/Categories?$orderby=CategoryName

This also means that not every LINQ query can be translated into an HTTP GET. For instance, what if we just wanted to display a couple properties of the category in the combobox. You would think we'd be able to do something like this:

Dim cats = From c In ctx.Categories _
           Order By c.CategoryName _
           Select c.CategoryName, c.Description

Unfortunately if you try to do this you'll get a NotSupportedException thrown at you by the client framework because it can't translate the query into an HTTP GET. In this case you need to pull down the category entities you want first and then you can project over those to create a list of anonymous types with only the properties you specify. But remember that you need to "execute" the query against the service first and then query over the list that is returned. Here's a way to do this:

Dim cats = From c In ctx.Categories _
           Order By c.CategoryName

'Now project only the properties we want. 
' Call ToList() on the first query to execute the service call
Dim results = From c In cats.ToList() _
              Select c.CategoryName, c.Description

For more details on what is and isn't supported refer to this article.

Intercepting Queries

Now that we understand how queries work we can start messing with how they execute. :-) Say we want to query only the Products in Northwind that are not Discontinued. We could write the query against our data service that specified the filter like so:

Dim products = From p In ctx.Products _
               Where p.Discontinued = False _
               Order By p.ProductName

Or we could write the raw URI:

Dim productURI As New Uri("Products()?$filter=Discontinued%20eq%20false&$orderby=ProductName", _
                          UriKind.Relative)

Dim products = ctx.Execute(Of Product)(productURI)

But what if it was a new requirement of the entire system that nowhere should we be displaying discontinued products? If this is the case we should be enforcing this on our data service instead. This can be done using query interceptors on the service. The way we create these is we annotate a method on our service with the QueryInterceptor attribute. The method you write must follow these rules:

  • The method must have public scope and be annotated with the QueryInterceptorAttribute, taking the name of a entity set as a parameter.
  • The method must accept no parameters.
  • The method must return an expression of type System.Linq.Expressions.Expression(Of Func(Of T, Boolean)) that is the filter to be composed for the entity set.

The first two requirements are easy the third may be confusing if you've never played with lambda expressions. Basically what happens is you specify additional filtering to apply onto the incoming query via this lambda. So in order to append our condition that we should only be returning products that are not discontinued we can add this method to our data service:

Imports System.Data.Services
Imports System.Linq
Imports System.ServiceModel.Web
Imports System.Linq.Expressions

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.All)
    End Sub
    <QueryInterceptor("Products")> _
    Public Function FilterProducts() As Expression(Of Func(Of Product, Boolean))
        'Only return products that are not discontinued
        Return Function(p) p.Discontinued = False
    End Function

End Class

Now we can write our queries without specifying the additional filter on discontinued and this will not be sent to the service from our client in the HTTP GET but will be executed against our database. The query interceptor will execute regardless if we write a LINQ query or feed it the raw URI.

LINQ Query:

Dim products = From p In ctx.Products Order By p.ProductName
Me.ListView1.ItemsSource = products.ToList()

URI:

Dim productURI As New Uri("Products()?$orderby=ProductName", UriKind.Relative)
Dim products = ctx.Execute(Of Product)(productURI)
Me.ListView1.ItemsSource = products.ToList()
AstoriaQuery1 

Pretty slick. You could of course do other processing here first. And you can also specify your own additional service operations as well by attributing them with a <WebGet> attribute. More on those in a later post.

Validation with Change Interceptors

You can also add methods to your service that will execute when changes are submitted. This allows us to add validation or other processing onto the data being submitted to the database. You do this by attributing a method in the data service with the ChangeInterceptor attribute. A change interceptor will pass the entity being saved and a parameter that indicates what update operation is being performed. For instance, say we want to put a validation on our ProductName so that users cannot submit empty product names to the database. We could write a method in our data service like so:

<ChangeInterceptor("Products")> _
Public Sub OnChangeProducts(ByVal p As Product, ByVal ops As UpdateOperations)
    If ops = UpdateOperations.Add OrElse ops = UpdateOperations.Change Then
        'Do not allow products with empty names
        If p.ProductName = "" Then
            Throw New DataServiceException(400, "Product name cannot be empty")
        End If
    End If
End Sub

When we throw a DataServiceException we can specify the HTTP status code and the message to return to the client. 400 indicates "Bad Request" and we pass the message on what the problem was. So if we try to submit a new or existing product with no product name we will get an HTTP error as seen in Fiddler:

AstoriaChange1

This is what's happening on the wire when we call SaveChanges and the exception is caught on the client. This prevents our data from being invalid no matter what client it's coming from. However this isn't that user-friendly to say the least. If we're building a smart client it's much better to put this type of validations on the client as well.

We can do this in our WPF client by extending the Product partial class and implementing IDataErrorInfo and adding our validation. On the WPF client create a new class called Product and place it in the same exact namespace as the NorthwindEntities data service client proxy that is generated for us when we add the service reference. It's called NorthwindService in our case. Then we can overwrite the partial method OnProductNameChanging to do the client-side validation. This method is called from the ProductName property setter in the generated entity on the client. Here's an example of how we can collect validation messages on the Product.

Imports WpfClient.NorthwindService
Imports System.ComponentModel

Namespace NorthwindService

    Partial Public Class Product
        Implements IDataErrorInfo

        Private Sub OnProductNameChanging(ByVal value As String)
            If value Is Nothing OrElse value.Trim = "" Then
                Me.AddError("ProductName", "Product name cannot be empty")
            Else
                Me.RemoveError("ProductName")
            End If
        End Sub

#Region "IDataErrorInfo Members"
        Private m_validationErrors As New Dictionary(Of String, String)

        Private Sub AddError(ByVal columnName As String, ByVal msg As String)
            If Not m_validationErrors.ContainsKey(columnName) Then
                m_validationErrors.Add(columnName, msg)
            End If
        End Sub

        Private Sub RemoveError(ByVal columnName As String)
            If m_validationErrors.ContainsKey(columnName) Then
                m_validationErrors.Remove(columnName)
            End If
        End Sub

        Friend ReadOnly Property HasErrors() As Boolean
            Get
                Return (Me.Error IsNot Nothing)
            End Get
        End Property

        Friend ReadOnly Property [Error]() As String _
Implements
System.ComponentModel.IDataErrorInfo.Error Get If m_validationErrors.Count > 0 Then Return "Product Data is invalid" Else Return Nothing End If End Get End Property Default Friend ReadOnly Property Item(ByVal columnName As String) As String _
Implements
System.ComponentModel.IDataErrorInfo.Item Get If m_validationErrors.ContainsKey(columnName) Then Return m_validationErrors(columnName).ToString Else Return Nothing End If End Get End Property #End Region End Class End Namespace

Notice that the client-side IDataErrorInfo properties are declared as Friend (internal) so that they are not serialized back up to the server. Next we need to make sure the bindings in the XAML of our ProductDetail form is set up to display the error.

<TextBox 
 Text="{Binding Path=ProductName, ValidatesOnDataErrors=True}"
 Height="25" Name="TextBox1" Width="180" Margin="3" HorizontalAlignment="Left" />

You can also add a validation ErrorTemplate if you like. I've shown this validation technique with WPF here before. So when we don't enter the ProductName on a product we can display the problem to the user right away without bothering our data service:

AstoriaChange2

Check out the updated sample on Code Galley, in there I also implement IEditableObject so that users can cancel out of editing of the products.

However, the fact that we have to put rules in two locations in our code is a total drag. If we could type share the entity partial classes on the server and the client then we could write this code in one place and run it in both the client and the server. This is why if you have complex business rules you're probably better off creating your own DataContracts and implementing your own WCF services. However, applications like this that have simple validation and heavy CRUD requirements make it a perfect candidate to use ADO.NET Data Services.

Next post I'll show how we can query and edit tabular data inside of an Excel client and post changes back to the data service.

Enjoy!

Leave a Comment
  • Please add 4 and 4 and type the answer here:
  • Post
  • PingBack from http://windows7news.com.au/2009/01/22/adonet-data-services-intercepting-queries-and-adding-validation/

  • Last few posts I've been building a WPF client against ADO.NET Data Services, if you missed them: Using

  • Last few posts I&#39;ve been building a WPF client against ADO.NET Data Services, if you missed them

  • Thank you for submitting this cool story - Trackback from DotNetShoutout

  • When I try and test your project I get the follownig error when trying to save the changes.

    Error processing request stream. The property name 'Error' specified for type 'NorthwindModel.Product' is not valid.

    THis is a great example except for this error that I am getting.

  • Hi Patrick,

    Good catch. I should have declared those client-side properties as Friend so they don't serialize back up to the server. I fixed the code.

    Thanks!

    -B

  • Thanks . Wonderful article....

    I faced the same issue as Patrick faced that serialize issue with Error properties....And I dont know VB...So what needs to be access specifier in case of C# instead of Friend...??

  • Beth,

    I spent the night looking for an eloquent solution to implementing validation before saving the data back to my Data Service. This totally fits the bill.

    Thanks!

  • I'm wondering when will be possible to use group by with ado data services. it wuold be really nice!

Page 1 of 1 (9 items)