I recently purchased the 35-page
Migrating to LINQ to SQL in TheBeerHouse Wrox Blox (for those of you who haven't seen it, get it - it's well worth the price and the time). I wanted to share some thoughts I had about it, and about using LINQ with N-tier architecture in general.
First, my congrats to author Doug Parsons for a clear, concise, and very well-written work. Nice job Doug!
That being said... I have to admit that, as I worked through it, something bothered me about the approach that was taken. In it, the author migrates the Articles module to LINQ to SQL by altering the methods in the BLL's Articles class (Articles.cs). Or course, this is perfectly valid - the application behaves exactly as the original. Despite this, I would respectfully suggest this isn't the best approach for TheBeerHouse, and in fact wouldn't really be the ideal approach for any N-tier application.
The Wrox Blox Approach
Upon reflection, it seems to me there are two general approaches to developing an application using LINQ to SQL. The first, which I'll call the "Thin DAL" approach, involves creating the LINQ to SQL file (.dbml), and letting the data entities it generates become the data object model. This is the way it's done in
Migrating to LINQ to SQL in TheBeerHouse.
Again, while valid, here are a few of the issues I see in using this approach:
- It abandons the three-tier architecture central to the original application. That's right - you're now placing the data access code directly in the business layer, a violation of the basic tenet of N-tier. If you take a look at the entities in the TBH.designer.cs file, you'll find there isn't a single line of data access code in it. The real data access code, which is actually generated by the DataContext objects, must be placed elsewhere - in this case, in the BLL. The .dbml may look and act somewhat like a data access layer, but it's not a true DAL by any means.
- It abandons the data provider model so pervasively used throughout the original book. One of the best things about the architecture of the original TheBeerHouse is the ingenious use of the provider model to create a totally "plug-and-play" DAL. Want to switch from SQL to Oracle? No worries - just write a new provider. Once you place the data access code in the business layer, you've given up that functionality.
- Want to host the Articles data in a different database, or on a different machine? Simple - just create a new connection string in web.config, and point to it in the <articles> element in the <TheBeerHouse> configuration section. But (at least the way it's written in the Wrox Blox) that doesn't work anymore either. You'll notice that when you create the TBH.dbml file, it automatically generates a new connection string and kindly places it in web.config for you -- ignoring your custom setting!
I could go on, but you get the point.
An Alternate Approach
I decided to tackle this from a totally different perspective. Foremost, I wanted to achieve the following:
- Retain the original three-tier architecture.
- Retain the original "plug-and-play" data provider model.
- Retain the web.config custom settings functionality.
This is the second approach to LINQ To SQL development - creating a real, fully separate DAL with it -- which isn't anything new at all, since it's the same proven approach we've been using all along.
In addition, I decided I wanted to do it
without using any T-SQL at all - no stored procedures, no database user functions, no nothing. Now, I understand that SPROCS have their place -- LINQ to SQL makes it pretty easy to use SPROCS, functions, and even free-form T-SQL strings. The Wrox Blox makes liberal use of the existing SPROCS. There's absolutely nothing wrong with that - in fact, in some cases it might even be better. However, at least to me, one of the big advantages of LINQ to SQL is freedom from T-SQL! I hate writing SQL more than I hate Brussels sprouts. So, I wanted to show that you could, if you wanted, migrate the app (or write a similar one from scratch) using only LINQ queries, while still retaining all the performance of the original SPROCS -- including lazy-loading of the Body property as implemented by the original solution.
Setting It Up
How do we do all this? Easy... and if you've been paying attention, you probably already guessed. Instead of abandoning the DAL, I simply wrote a new provider, this time using LINQ to SQL.
Of course, you still have to create the DataContext entities, so I created a TBH.dbml file exactly as it was done in the Wrox Blox. I placed this in a new folder, under the DAL folder in App_Code, called [u]LinqToSql</u>. I added a namespace in the TBH.designer.cs file called MB.TheBeerHouse.DAL.LingToSql. However, unlike the Wrox Blox, I didn't drag any stored procedures into the method pane. Remember, I'm not going to be using any SPOCS here.
I then created a new class in the same folder, called LinqToSqlArticlesProvider, under the same namespace, and inheriting from the ArticlesProvider abstract class.
Of course, then you have to implement all of those abstract methods! But first, I added a simple extra property to my new LinqToSqlArticlesProvider:
protected TBHDataContext Context = new TBHDataContext(MB.TheBeerHouse.Globals.Settings.Ar ticles.ConnectionString);
Attribute it to laziness if you must. I just hate having to instantiate a new DataContext every time you want to write a LINQ query -- and I knew I'd be writing a lot of LINQ queries! This way, I could just have a property to hold the DataContext and refer to it as needed.
Notice also that the DataContext retrieves the connection string from the Globals class, which is pulled from web.config.
Oh, and speaking of web.config... since we're using a new provider class, we want to make sure to specify that like so:
<articles pageSize="10" providerType="MB.TheBeerHouse.DAL.LinqToSql.LinqTo SqlArticlesProvider"/>
Implementing the Methods
Once you get to this point, it's all pretty straightforward. You just have to slog through all the "Get-this" and "Update-that" stuff. In fact, I'll leave it up to you adventurous souls to give it a try yourselves. (I'll post the whole provider code at the end.) First, I'd like to point out a few of the more interesting bits:
EDIT: The finished working code is available for download at
http://www.originaltalent.com/downlo..._Alternate.zip (thanks, jimibt!)
- Remember that the business layer expects to see objects with data entity types as defined in the DAL (ArticlesProvider.cs), not in the DataContext (TBH.designer.cs). That means, for example, in GetCategoryByID, you're returning CategoryDetails, not tbh_Category; in GetCategories, you're returning List<CategoryDetails>, not List<tbh_Category>. The same applies to Articles and Comments.
- In queries where you need to return a single object (Get???ByID, or the various Delete and Update methods), you'll be using the .SingleOrDefault() method on the query result. This will return the one and only matching record. If more than a single matching record is found, an exception is thrown; if no matching records are found, it returns null.
- In the methods where you're returning collections of Articles, you can save bandwidth by not fetching the Body from the database. The body is never displayed in lists, but only when the whole article is requested. You can do that by returning an anonymous type that doesn't contain Body, and passing null to the ArticleDetails constructor in place of the article.Body parameter.
- In the methods that return counts, you'll be using the IQueryable<T> Count() method.
- In overloads that employ paging, you'll be using the .Skip() and .Take() methods on the query results. The formula for doing this, given a pageIndex and pageSize, is .Skip(pageIndex * pageSize).Take(pageSize).
The LinqToSqlArticlesProvider class
using System;
using System.Collections.Generic;
using System.Data.Linq;
using System.Linq;
namespace MB.TheBeerHouse.DAL.LinqToSql
{
public class LinqToSqlArticlesProvider : ArticlesProvider
{
protected TBHDataContext Context = new TBHDataContext(MB.TheBeerHouse.Globals.Settings.Ar ticles.ConnectionString);
/// <summary>
/// Returns a collection with all the categories
/// </summary>
public override List<CategoryDetails> GetCategories()
{
List<CategoryDetails> categoryDetailsCollection = new List<CategoryDetails>();
IOrderedQueryable<tbh_Category> categories =
from category in this.Context.tbh_Categories
orderby category.Title ascending
orderby category.Importance descending
select category;
foreach (tbh_Category category in categories)
{
CategoryDetails categoryDetails = new CategoryDetails(
category.CategoryID, category.AddedDate, category.AddedBy, category.Title,
category.Importance, category.Description, category.ImageUrl);
categoryDetailsCollection.Add(categoryDetails);
}
return categoryDetailsCollection;
}
/// <summary>
/// Returns an existing category with the specified ID
/// </summary>
public override CategoryDetails GetCategoryByID(int categoryID)
{
tbh_Category category =
(from cat in this.Context.tbh_Categories
where cat.CategoryID == categoryID
select cat).SingleOrDefault();
if (category != null)
{
CategoryDetails categoryDetails = new CategoryDetails(category.CategoryID,
category.AddedDate, category.AddedBy, category.Title, category.Importance,
category.Description, category.ImageUrl);
return categoryDetails;
}
else
return null;
}
/// <summary>
/// Deletes a category
/// </summary>
public override bool DeleteCategory(int categoryID)
{
tbh_Category category =
(from cat in this.Context.tbh_Categories
where cat.CategoryID == categoryID
select cat).SingleOrDefault();
this.Context.tbh_Categories.DeleteOnSubmit(categor y);
ChangeSet cs = this.Context.GetChangeSet();
this.Context.SubmitChanges();
return (cs.Deletes.Count == 1);
}
/// <summary>
/// Updates a category
/// </summary>
public override bool UpdateCategory(CategoryDetails category)
{
tbh_Category cat =
(from c in this.Context.tbh_Categories
where c.CategoryID == category.ID
select c).SingleOrDefault();
cat.AddedBy = category.AddedBy;
cat.AddedDate = category.AddedDate;
cat.Description = category.Description;
cat.ImageUrl = category.ImageUrl;
cat.Importance = category.Importance;
cat.Title = category.Title;
ChangeSet cs = this.Context.GetChangeSet();
this.Context.SubmitChanges();
return (cs.Updates.Count == 1);
}
/// <summary>
/// Creates a new category
/// </summary>
public override int InsertCategory(CategoryDetails category)
{
tbh_Category cat = new tbh_Category();
cat.AddedBy = category.AddedBy;
cat.AddedDate = category.AddedDate;
cat.Description = category.Description;
cat.ImageUrl = category.ImageUrl;
cat.Importance = category.Importance;
cat.Title = category.Title;
this.Context.tbh_Categories.InsertOnSubmit(cat);
this.Context.SubmitChanges();
return cat.CategoryID;
}
/// <summary>
/// Retrieves all articles
/// </summary>
public override List<ArticleDetails> GetArticles(int pageIndex, int pageSize)
{
List<ArticleDetails> articleDetailsCollection = new List<ArticleDetails>();
var articles =
(from article in this.Context.tbh_Articles
orderby article.ReleaseDate descending
select new
{
article.ArticleID,
article.AddedDate,
article.AddedBy,
article.CategoryID,
CategoryTitle = article.tbh_Category.Title,
article.Title,
article.Abstract,
article.Country,
article.State,
article.City,
article.ReleaseDate,
article.ExpireDate,
article.Approved,
article.Listed,
article.CommentsEnabled,
article.OnlyForMembers,
article.ViewCount,
article.Votes,
article.TotalRating
}).Skip(pageIndex * pageSize).Take(pageSize);
foreach (var article in articles)
{
ArticleDetails articleDetails = new ArticleDetails(article.ArticleID,
article.AddedDate, article.AddedBy, article.CategoryID,
article.CategoryTitle, article.Title, article.Abstract, null,
article.Country, article.State, article.City,
(DateTime)article.ReleaseDate, (DateTime)article.ExpireDate,
article.Approved, article.Listed, article.CommentsEnabled,
article.OnlyForMembers, article.ViewCount, article.Votes,
article.TotalRating);
articleDetailsCollection.Add(articleDetails);
}
return articleDetailsCollection;
}
/// <summary>
/// Returns the total number of articles
/// </summary>
public override int GetArticleCount()
{
return this.Context.tbh_Articles.Count();
}
/// <summary>
/// Retrieves all articles for the specified category
/// </summary>
public override List<ArticleDetails> GetArticles(int categoryID, int pageIndex, int pageSize)
{
List<ArticleDetails> articleDetailsCollection = new List<ArticleDetails>();
var articles =
(from article in this.Context.tbh_Articles
where article.CategoryID == categoryID
orderby article.ReleaseDate descending
select new
{
article.ArticleID,
article.AddedDate,
article.AddedBy,
article.CategoryID,
CategoryTitle = article.tbh_Category.Title,
article.Title,
article.Abstract,
article.Country,
article.State,
article.City,
article.ReleaseDate,
article.ExpireDate,
article.Approved,
article.Listed,
article.CommentsEnabled,
article.OnlyForMembers,
article.ViewCount,
article.Votes,
article.TotalRating
}).Skip(pageIndex * pageSize).Take(pageSize);
foreach (var article in articles)
{
ArticleDetails articleDetails = new ArticleDetails(article.ArticleID,
article.AddedDate, article.AddedBy, article.CategoryID,
article.CategoryTitle, article.Title, article.Abstract, null,
article.Country, article.State, article.City,
(DateTime)article.ReleaseDate, (DateTime)article.ExpireDate,
article.Approved, article.Listed, article.CommentsEnabled,
article.OnlyForMembers, article.ViewCount, article.Votes,
article.TotalRating);
articleDetailsCollection.Add(articleDetails);
}
return articleDetailsCollection;
}
/// <summary>
/// Returns the total number of articles for the specified category
/// </summary>
public override int GetArticleCount(int categoryID)
{
return (from article in this.Context.tbh_Articles
where article.CategoryID == categoryID
select article).Count();
}
/// <summary>
/// Retrieves all published articles
/// </summary>
public override List<ArticleDetails> GetPublishedArticles(DateTime currentDate, int pageIndex, int pageSize)
{
List<ArticleDetails> articleDetailsCollection = new List<ArticleDetails>();
var articles =
(from article in this.Context.tbh_Articles
where article.Approved &&
article.Listed &&
article.ReleaseDate <= currentDate &&
article.ExpireDate > currentDate
orderby article.ReleaseDate descending
select new
{
article.ArticleID,
article.AddedDate,
article.AddedBy,
article.CategoryID,
CategoryTitle = article.tbh_Category.Title,
article.Title,
article.Abstract,
article.Country,
article.State,
article.City,
article.ReleaseDate,
article.ExpireDate,
article.Approved,
article.Listed,
article.CommentsEnabled,
article.OnlyForMembers,
article.ViewCount,
article.Votes,
article.TotalRating
}).Skip(pageIndex * pageSize).Take(pageSize);
foreach (var article in articles)
{
ArticleDetails articleDetails = new ArticleDetails(article.ArticleID,
article.AddedDate, article.AddedBy, article.CategoryID,
article.CategoryTitle, article.Title, article.Abstract, null,
article.Country, article.State, article.City,
(DateTime)article.ReleaseDate, (DateTime)article.ExpireDate,
article.Approved, article.Listed, article.CommentsEnabled,
article.OnlyForMembers, article.ViewCount, article.Votes,
article.TotalRating);
articleDetailsCollection.Add(articleDetails);
}
return articleDetailsCollection;
}
/// <summary>
/// Returns the total number of published articles
/// </summary>
public override int GetPublishedArticleCount(DateTime currentDate)
{
return (from article in this.Context.tbh_Articles
where article.Approved &&
article.Listed &&
article.ReleaseDate <= currentDate &&
article.ExpireDate > currentDate
select article).Count();
}
/// <summary>
/// Retrieves all published articles for the specified category
/// </summary>
public override List<ArticleDetails> GetPublishedArticles(int categoryID, DateTime currentDate, int pageIndex, int pageSize)
{
List<ArticleDetails> articleDetailsCollection = new List<ArticleDetails>();
var articles =
(from article in this.Context.tbh_Articles
where article.CategoryID == categoryID &&
article.Approved &&
article.Listed &&
article.ReleaseDate <= currentDate &&
article.ExpireDate > currentDate
orderby article.ReleaseDate descending
select new
{
article.ArticleID,
article.AddedDate,
article.AddedBy,
article.CategoryID,
CategoryTitle = article.tbh_Category.Title,
article.Title,
article.Abstract,
article.Country,
article.State,
article.City,
article.ReleaseDate,
article.ExpireDate,
article.Approved,
article.Listed,
article.CommentsEnabled,
article.OnlyForMembers,
article.ViewCount,
article.Votes,
article.TotalRating
}).Skip(pageIndex * pageSize).Take(pageSize);
foreach (var article in articles)
{
ArticleDetails articleDetails = new ArticleDetails(article.ArticleID,
article.AddedDate, article.AddedBy, article.CategoryID,
article.CategoryTitle, article.Title, article.Abstract, null,
article.Country, article.State, article.City,
(DateTime)article.ReleaseDate, (DateTime)article.ExpireDate,
article.Approved, article.Listed, article.CommentsEnabled,
article.OnlyForMembers, article.ViewCount, article.Votes,
article.TotalRating);
articleDetailsCollection.Add(articleDetails);
}
return articleDetailsCollection;
}
/// <summary>
/// Returns the total number of published articles for the specified category
/// </summary>
public override int GetPublishedArticleCount(int categoryID, DateTime currentDate)
{
return (from article in this.Context.tbh_Articles
where article.CategoryID == categoryID &&
article.Approved &&
article.Listed &&
article.ReleaseDate <= currentDate &&
article.ExpireDate > currentDate
select article).Count();
}
/// <summary>
/// Retrieves the article with the specified ID
/// </summary>
public override ArticleDetails GetArticleByID(int articleID)
{
tbh_Article article =
(from art in this.Context.tbh_Articles
where art.ArticleID == articleID
select art).SingleOrDefault();
if (article != null)
{
ArticleDetails articleDetails = new ArticleDetails(article.ArticleID,
article.AddedDate, article.AddedBy, article.CategoryID,
article.tbh_Category.Title, article.Title, article.Abstract,
article.Body, article.Country, article.State, article.City,
(DateTime)article.ReleaseDate, (DateTime)article.ExpireDate,
article.Approved, article.Listed, article.CommentsEnabled,
article.OnlyForMembers, article.ViewCount, article.Votes,
article.TotalRating);
return articleDetails;
}
else
return null;
}
/// <summary>
/// Retrieves the body for the article with the specified ID
/// </summary>
public override string GetArticleBody(int articleID)
{
return (from article in this.Context.tbh_Articles
where article.ArticleID == articleID
select new { article.Body }).Single().Body;
}
/// <summary>
/// Deletes an article
/// </summary>
public override bool DeleteArticle(int articleID)
{
tbh_Article article =
(from art in this.Context.tbh_Articles
where art.ArticleID == articleID
select art).SingleOrDefault();
this.Context.tbh_Articles.DeleteOnSubmit(article);
ChangeSet cs = this.Context.GetChangeSet();
this.Context.SubmitChanges();
return (cs.Deletes.Count == 1);
}
/// <summary>
/// Inserts a new article
/// </summary>
public override int InsertArticle(ArticleDetails article)
{
tbh_Article art = new tbh_Article();
art.AddedDate = article.AddedDate;
art.AddedBy = article.AddedBy;
art.CategoryID = article.CategoryID;
art.Title = article.Title;
art.Abstract = article.Abstract;
art.Body = article.Body;
art.Country = article.Country;
art.State = article.State;
art.City = article.City;
art.ReleaseDate = article.ReleaseDate;
art.ExpireDate = article.ExpireDate;
art.Approved = article.Approved;
art.Listed = article.Listed;
art.CommentsEnabled = article.CommentsEnabled;
art.OnlyForMembers = article.OnlyForMembers;
this.Context.tbh_Articles.InsertOnSubmit(art);
this.Context.SubmitChanges();
return art.ArticleID;
}
/// <summary>
/// Updates an article
/// </summary>
public override bool UpdateArticle(ArticleDetails article)
{
tbh_Article art =
(from a in this.Context.tbh_Articles
where a.ArticleID == article.ID
select a).SingleOrDefault();
art.CategoryID = article.CategoryID;
art.Title = article.Title;
art.Abstract = article.Abstract;
art.Body = article.Body;
art.Country = article.Country;
art.State = article.State;
art.City = article.City;
art.ReleaseDate = article.ReleaseDate;
art.ExpireDate = article.ExpireDate;
art.Approved = article.Approved;
art.Listed = article.Listed;
art.CommentsEnabled = article.CommentsEnabled;
art.OnlyForMembers = article.OnlyForMembers;
ChangeSet cs = this.Context.GetChangeSet();
this.Context.SubmitChanges();
return (cs.Updates.Count == 1);
}
/// <summary>
/// Approves an article
/// </summary>
public override bool ApproveArticle(int articleID)
{
tbh_Article article =
(from art in this.Context.tbh_Articles
where art.ArticleID == articleID
select art).SingleOrDefault();
article.Approved = true;
ChangeSet cs = this.Context.GetChangeSet();
this.Context.SubmitChanges();
return (cs.Updates.Count == 1);
}
/// <summary>
/// Increments the ViewCount of the specified article
/// </summary>
public override bool IncrementArticleViewCount(int articleID)
{
tbh_Article article =
(from art in this.Context.tbh_Articles
where art.ArticleID == articleID
select art).SingleOrDefault();
article.ViewCount++;
ChangeSet cs = this.Context.GetChangeSet();
this.Context.SubmitChanges();
return (cs.Updates.Count == 1);
}
/// <summary>
/// Inserts a vote for the specified article
/// </summary>
public override bool RateArticle(int articleID, int rating)
{
tbh_Article article =
(from art in this.Context.tbh_Articles
where art.ArticleID == articleID
select art).SingleOrDefault();
article.Votes++;
article.TotalRating += rating;
ChangeSet cs = this.Context.GetChangeSet();
this.Context.SubmitChanges();
return (cs.Updates.Count == 1);
}
/// <summary>
/// Retrieves all comments
/// </summary>
public override List<CommentDetails> GetComments(int pageIndex, int pageSize)
{
List<CommentDetails> commentDetailsCollection = new List<CommentDetails>();
IQueryable<tbh_Comment> comments =
(from comment in this.Context.tbh_Comments
orderby comment.AddedDate descending
select comment).Skip(pageIndex * pageSize).Take(pageSize);
foreach (tbh_Comment comment in comments)
{
CommentDetails commentDetails = new CommentDetails(comment.CommentID,
comment.AddedDate, comment.AddedBy, comment.AddedByEmail,
comment.AddedByIP, comment.ArticleID, comment.tbh_Article.Title,
comment.Body);
commentDetailsCollection.Add(commentDetails);
}
return commentDetailsCollection;
}
/// <summary>
/// Returns the total number of comments
/// </summary>
public override int GetCommentCount()
{
return this.Context.tbh_Comments.Count();
}
/// <summary>
/// Retrieves all comments for the specified article
/// </summary>
public override List<CommentDetails> GetComments(int articleID, int pageIndex, int pageSize)
{
List<CommentDetails> commentDetailsCollection = new List<CommentDetails>();
IQueryable<tbh_Comment> comments =
(from comment in this.Context.tbh_Comments
where comment.ArticleID == articleID
orderby comment.AddedDate descending
select comment).Skip(pageIndex * pageSize).Take(pageSize);
foreach (tbh_Comment comment in comments)
{
CommentDetails commentDetails = new CommentDetails(comment.CommentID,
comment.AddedDate, comment.AddedBy, comment.AddedByEmail,
comment.AddedByIP, comment.ArticleID, comment.tbh_Article.Title,
comment.Body);
commentDetailsCollection.Add(commentDetails);
}
return commentDetailsCollection;
}
/// <summary>
/// Returns the total number of comments for the specified article
/// </summary>
public override int GetCommentCount(int articleID)
{
return (from comment in this.Context.tbh_Comments
where comment.ArticleID == articleID
select comment).Count();
}
/// <summary>
/// Retrieves the comment with the specified ID
/// </summary>
public override CommentDetails GetCommentByID(int commentID)
{
tbh_Comment comment =
(from com in this.Context.tbh_Comments
where com.CommentID == commentID
select com).SingleOrDefault();
if (comment != null)
{
CommentDetails commentDetails = new CommentDetails(comment.CommentID,
comment.AddedDate, comment.AddedBy, comment.AddedByEmail,
comment.AddedByIP, comment.ArticleID, comment.tbh_Article.Title,
comment.Body);
return commentDetails;
}
else
return null;
}
/// <summary>
/// Deletes a comment
/// </summary>
public override bool DeleteComment(int commentID)
{
tbh_Comment comment =
(from com in this.Context.tbh_Comments
where com.CommentID == commentID
select com).SingleOrDefault();
this.Context.tbh_Comments.DeleteOnSubmit(comment);
ChangeSet cs = this.Context.GetChangeSet();
this.Context.SubmitChanges();
return (cs.Deletes.Count == 1);
}
/// <summary>
/// Inserts a new comment
/// </summary>
public override int InsertComment(CommentDetails comment)
{
tbh_Comment com = new tbh_Comment();
com.AddedBy = comment.AddedBy;
com.AddedByEmail = comment.AddedByEmail;
com.AddedByIP = comment.AddedByIP;
com.AddedDate = comment.AddedDate;
com.ArticleID = comment.ArticleID;
com.Body = comment.Body;
this.Context.tbh_Comments.InsertOnSubmit(com);
this.Context.SubmitChanges();
return com.CommentID;
}
/// <summary>
/// Updates an comment
/// </summary>
public override bool UpdateComment(CommentDetails comment)
{
tbh_Comment com =
(from c in this.Context.tbh_Comments
where c.CommentID == comment.ID
select c).SingleOrDefault();
com.Body = comment.Body;
ChangeSet cs =