Pipe and Filters, Fluent APIs and LINQ to SQL

Ayende issued a challenge the other day which is becoming the new Enterprise FizzBuzz. The challenges runs roughly like this: build a command-line interface for retrieving a list (the first 10) of products from some data-storage mechanism (up to the implementer) and potentially filter the list using any number of restrictions imposed in any order by passing in parameters to the application.

As the challenge fits nicely with some of the points that I have been trying to make with LINQ to SQL I thought I'd give it a go. I've chosen to implement a simple solution with LINQ to SQL and the Pipes and Filters pattern. Let's have a look at the solution. Note that you can get the complete source from the Code Gallery page.

Firstly the data storage is up to the implementer so I've gone with SQL Server so that I can LINQ to SQL things up later on. If I were a little more disciplined (or less lazy) I might have written some unit tests against a generic IQueryable<T> repository and driven my domain model out of that. Looking at what I've ended up with I might give it a go. Here is the CAL for the data-model:

Products 
<- { Name } NVARCHAR(100)
<- { Price } MONEY
<> Tags
<- { _Text } NVARCHAR(50)



Running that through Crank will spin up my database. Mental note: Write a generator for Crank to produce LINQ to SQL data context and supporting classes.



Next I had to create the LINQ to SQL DataContext. I did this by creating a new LINQ to SQL Designer Surface and dragging my three tables onto it from the server explorer. Again, I'd consider doing this another way because the object model is a bit clumsy. Here is a snapshot:



Products Challenge Data Model



I spent some time messing with the DataContext to get it to create the database and populate it with Ayende's dummy data but it's all fairly boring stuff so I won't go into it here. We now have enough to start meeting requirements. Here's is all we'd need to print ALL of the products:



foreach (var product in new ProductsDataContext().Products)
Console.WriteLine("{0} ${1:0.00}", product.Name, product.Price);


Pretty cool huh? Rob Conery has some cool Pagination stuff but I thought it was overkill for what I wanted to do so I then dropped in a some simple Pagination stuff of my own which gives us:



var productsForDisplay = new ProductsDataContext()
.Products
.InPagesOf(10)
.Page(1);

foreach (var product in productsForDisplay)
Console.WriteLine("{0} ${1:0.00}", product.Name, product.Price);




The code for the InPagesOf extension method (and the PaginatedQueryable that supports it) is in the source code. The best apart about this is that when the database query gets executed, only 10 rows (max) are returned. I started to have some problems with unordered data though I moved some things around to give a default order to all Products when retrieved. In reality I still feel that Pagination and Ordering belong in the User Interface Layer and I could have done that here.



Next up I added the IFilter interface which has a single method:



public interface IFilter<T>
{
IQueryable<T> Filter(IQueryable<T> source);
}


This should allow us to write a filter that knows nothing about the source of the data it just knows how to allow some T's through and stop other T's (it's a T-filter). Because it accepts and returns the same type we can chain filters together. This is the purpose of the generic Pipeline class. Here's it's Filter method:



public IQueryable<T> Filter(IQueryable<T> source)
{
var filteredValues = source;
foreach (var filter in Filters)
filteredValues = filter.Filter(filteredValues);
return filteredValues;
}

Notice that the Pipeline<T> is itself an IFilter<T>. This is great because it means we can take a Pipeline and another Filter and add them to a second Pipeline. This is a good example of the Composite Design Pattern.

So given that we receive a list of Tags from the command line to filter our list by we need a TagFilter that will allow Products through if they are tagged with a specific tag. Here is the Filter method from the TagFilter:



public IQueryable<Product> Filter(IQueryable<Product> source)
{
return from product in source
where product.ProductTags.Count( pt => pt.Tag.TagText == TagName ) > 0
select product;
}



This basically says that we should return any products whose ProductTags collection contains more than zero entries for the specified TagName. Now we need to construct a Pipeline of TagFilters from the tags specified in the command line. Here is a static method to do that:



public static IFilter<Product> TaggedWith(IEnumerable<string> tags)
{
var pipeline = new Pipeline<Product>();
foreach (var tag in tags)
pipeline.Filters.Add(new TagFilter(tag));
return pipeline;
}



It's in a static class called Products. Mix that in with an extension method to apply filters to an existing IQueryable<T> and now our list generation looks like this:



var productsForDisplay = new ProductsRepository()
.AllProducts
.WhichAre(Products.TaggedWith(searchTerms))
.InPagesOf(10)
.Page(1);



This is my first attempt at creating a Fluent Interface and I keep changing it. I decided to quit it and just post it already but it's much harder than I thought it would be. Has anyone seen any good guidance on this?



This is nice and Ayende showed something similar previously using IEnumerables. The best part is when you consider that LINQs deferred execution scheme changes the game. In the original IEnumerable method the pipes and filters method only really works if you can generate everything up front and then push things through the pipe. This would have meant loading all of the products into memory or writing some complicated stuff.



With IQueryable<T> and LINQ the story is different. Even though we have implemented manipulations to our collection of products in a variety of different places (TagFilter, Pipeline<Product>, PaginatedQueryable<Product>) and attached them in different ways (well mainly with extension methods but you could) they all get combined and munged into a single SQL statement which only draws back the Products that we want to display (and in the right order too). Let's turn logging on for a second and have a look at what gets produced for 2 criteria:



SELECT TOP (10) [t0].[Name], [t0].[Price], [t0].[ProductID]
FROM [dbo].[Products] AS [t0]
WHERE (((
SELECT COUNT(*)
FROM [dbo].[ProductTags] AS [t1]
INNER JOIN [dbo].[Tags] AS [t2] ON [t2].[TagID] = [t1].[TagID]
WHERE ([t2].[TagText] = @p0) AND ([t1].[ProductID] = [t0].[ProductID])
)) > @p1) AND (((
SELECT COUNT(*)
FROM [dbo].[ProductTags] AS [t3]
INNER JOIN [dbo].[Tags] AS [t4] ON [t4].[TagID] = [t3].[TagID]
WHERE ([t4].[TagText] = @p2) AND ([t3].[ProductID] = [t0].[ProductID])
)) > @p3)
ORDER BY [t0].[Price]
-- @p0: Input NVarChar (Size = 10; Prec = 0; Scale = 0) [vegetarian]
-- @p1: Input Int (Size = 0; Prec = 0; Scale = 0) [0]
-- @p2: Input NVarChar (Size = 4; Prec = 0; Scale = 0) [pg13]
-- @p3: Input Int (Size = 0; Prec = 0; Scale = 0) [0]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.21022.8



Run that against your database and you'll see only those records we were interested in being returned. I don't think that my method for filtering products by tag will scale particularly well but the idea is sound.



What do you think? Doing this we can build compose-able filtering systems for data in our database. If the client added a requirement that they wanted to filter by price-range or by the first three letters of the product name (or any other crazy thing a client may ask for) then we are well prepared to make the change by adding a PriceRangeFilter, a StartsWithFilter (and a CrazyClientRequest5Filter). Each new filter doesn't need to know where the data is and how it will be retrieved because it could work on any source of products that can be exposed as an IQueryable<Product>.



Remember that you can get the full source from the Pipelines, Filters, Fluent API and LINQ to SQL and any feedback is welcome there, here or at michael.minutillo@gmail.com

linq-to-sqlasp-net
Posted by: Mike Minutillo
Last revised: 27 May, 2011 02:42 PM History

Comments

No comments yet. Be the first!

No new comments are allowed on this post.