Spaanjaars.Toolkit.ContentRating: Version 1.1

Ever since I wrote the initial version of my ContentRating control back in 2006, I received a massive amount of feedback, both as comments below the article and as private e-mails. Not surprisingly, if you consider the article has been read over 19,000 times and has been rated 444 times (at the time of writing).

Besides getting a lot of "thank you's" from people who liked the control, I also got a lot of requests for a real-world example of a test site using the control. The test site that shipped with the control used fake data stored in ViewState to simulate a real backing store which obviously didn't cut it for a lot of people.

Also, a reader called vgt pointed out a bug in the control where an existing cookie would be overwritten by a new one one, effectively allowing you to vote for the previous item again.

Finally, I had a few requests of my own: I didn't like the default data source of 5 integers if you didn't supply a data source yourself. I also didn't like it that the control didn't raise an exception when you tried to data bind it without a valid data source.

So, enough reasons to fire up Visual Studio and get my hands dirty on some control fixing.

V1.1 of the Spaanjaars.Toolkit.ContentRating Control

(Note, if you haven't read the original article or looked at the control, now is a great time to do so).

With .NET 3.5 being out for more than half a year, and with Visual Web Developer 2008 Express Edition being completely free for everyone, I decided to upgrade the control to .NET 3.5. Don't worry if you're still on .NET 2.0: the actual .cs files of the control can easily be used in a .NET 2.0 project; simply start a new project and drop the files in the solution.

The test web site is a different story as it uses LINQ to get and update data. However, the database schema and stored procedure can easily be reused in your own .NET 2.0 application and accessed with a bit of ADO.NET code.

So What's New?

The core functionality of the control hasn't changed. Everything that was there is still there. However, I changed the implementation of the internal data source a little. Previously, it defaulted to this:

[ContentRating.cs]
private int[] _dataSource = new int[] { 0, 0, 0, 0, 0 };

This way, the control would always have a valid data source, whether you set one yourself or not. Now, in the new implementation, _dataSource defaults to null:

[ContentRating.cs]
private int[] dataSource;

Additionally, the data source is checked in the DataBind method:

[ContentRating.cs]
public override void DataBind()
{
  if (ItemId == null)
  {
    throw new ArgumentException(@"Can't bind a datasource 
        without a valid ItemId");
  }

  if (_dataSource == null || _dataSource.Length == 0)
  {
    throw new InvalidOperationException(
	       @"Can't bind without a valid data source.");
  }
  
  base.DataBind();
  Controls.Clear();
  ClearChildViewState();
  TrackViewState();
  ViewState["RateItems"] = _dataSource;
  CreateControlHierarchy(_dataSource);
  ChildControlsCreated = true;
}

This way, you get a crystal clear error message when you forget to set the data source, rather than the control silently defaulting to an array of 5 integers.

The second change I made was in the way I set the cookie. Previously, I had this code:

[ContentRating.cs]
if (!myArgs.Cancel)
{
  // Create a new cookie value for this rate.
  rateCookie = new HttpCookie("Rate");
  rateCookie.Expires = DateTime.Now.AddMonths(6);
  rateCookie.Values.Add(cookieKey, cookieValue);
  Context.Response.Cookies.Add(rateCookie);
  // Raise the Rated event to notify clients.
  OnRated(myArgs);
  handled = true;
}

This code simply ignored the fact that the cookie might already contain one or more keys. The code has now been changed to this

[ContentRating.cs]
if (!myArgs.Cancel)
{
  // Create a new cookie value for this rate.
  rateCookie = Context.Request.Cookies[CookieKey]; // CookieKey is a new constant

  if (rateCookie == null)
  {
    rateCookie = new HttpCookie(CookieKey);
  }
  rateCookie.Expires = DateTime.Now.AddMonths(6);
  rateCookie.Values[cookieSubKey] = cookieValue;
  Context.Response.Cookies.Add(rateCookie);
  // Raise the Rated event to notify clients.
  OnRated(myArgs); 
  handled = true;
}

Now the code checks if there is an existing cookie. If there's not, a new cookie is created. Either way, the sub key is added using the Values collection of the cookie.

That's pretty much what has changed in the control itself. I have made some minor changes (mostly evolving around naming conventions), so check out the ChangeLog.txt file that comes with the solution if you want to see all the changes.

The Sample Site

To make it easier to test out the control, I supplied a simple web site with the initial control's release. However, the fact that it used a fake data store made it difficult for a lot of people to implement this control in a real world web site. So, to accommodate for these people, I have updated the sample web site and supplied a SQL Server 2005 database with a single table called Rating. The table looks like this:

The Design of the Rating Table
Figure 1 - The Rating Table

This table simply stores the ID of a content item, and has five columns to store the rated values. Each of these columns keeps track of the number of times a user has given a specific rating to an item.

I rely on LINQ to get the rating values for me; you'll see how this works in a bit. However, to update a rating, I am using the following stored procedure that I have mapped to a method in my LINQ to SQL Classes design surface. The stored procedure looks like this:

[sprocRatingUpdate]
CREATE  PROCEDURE [dbo].[sprocRatingUpdate]
  /*  '===============================================================
  '   NAME:                sprocRatingUpdate
  '   DATE CREATED:        2008-07-16
  '   CREATED BY:          Imar Spaanjaars
  '   CREATED FOR:         Imar.Spaanjaars.Com
  '===============================================================*/

  @contentId int,
  @rateId int

  AS
  
  IF NOT EXISTS (SELECT 1 FROM Rating WHERE ContentId = @contentId)
  BEGIN
    INSERT Rating (ContentId) VALUES(@contentId)
  END
  
  IF (@rateId = 1)
  BEGIN
    UPDATE Rating SET Rating1 = Rating1 + 1 WHERE ContentId = @contentId
  END
  IF (@rateId = 2)
  BEGIN
    UPDATE Rating SET Rating2 = Rating2 + 1 WHERE ContentId = @contentId
  END
  IF (@rateId = 3)
  BEGIN
    UPDATE Rating SET Rating3 = Rating3 + 1 WHERE ContentId = @contentId
  END
  IF (@rateId = 4)
  BEGIN
    UPDATE Rating SET Rating4 = Rating4 + 1 WHERE ContentId = @contentId
  END
  IF (@rateId = 5)
  BEGIN
    UPDATE Rating SET Rating5 = Rating5 + 1 WHERE ContentId = @contentId
  END

In order to avoid Dynamic SQL, I have an UPDATE statement for each possible rate value, ranging from 1 to 5. I am sure there are cleaner ways to do this, but this one has always worked for me.

So, what happens when you pass a content ID and a rate value to this stored procedure? First, it tries to find an existing record for the requested ContentId. If the item doesn't exist, it is inserted. The remainder of the code then updates the (new or existing) item and increases the selected rate value by 1.

How is this procedure called in the sample web site you may wonder? Because of the LINQ to SQL diagram, there isn't much implementation. Getting a selected rating is as easy as firing a simple LINQ query:

[Default.aspx.cs]
using (RatingDataContext db = new RatingDataContext())
{
  int[] rateValues;

  // Get the item through LINQ. SingleOrDefault returns NULL when the item is not found.
  Rating result = (from ra in db.Ratings
                  where ra.ContentId == contentId
                  select ra).SingleOrDefault();

  if (result == null)
  {
    // Initialize to zero's
    rateValues = new int[] { 0, 0, 0, 0, 0 };
  }
  else
  {
    // Get the values from the result Rating instance.
    rateValues = new int[] { result.Rating1, result.Rating2, 
	              result.Rating3, result.Rating4, result.Rating5 };
  }

  ContentRating1.ItemId = ContentId;
  ContentRating1.DataSource = rateValues;
  ContentRating1.DataBind();
  lblContentId.Text = ContentId.ToString();
}

Notice how SingleOrDefault is called to get an instance of Rating or a a null value out of the LINQ query. This is useful if the item has never been rated before and thus has no associated record in the database.

If SingleOrDefault returned a value, it's used to initialize an array of integers based of the values on the result instance. Otherwise, an empty array of 5 integers is created.

Updating the Rating table is even easier. I mapped the stored procedure sprocRatingUpdate to a UpdateRating method in my model, so all I need to do now is call this method and pass two values, then call BindData to refresh the rating control:

[Default.aspx.cs]
protected void ContentRating1_Rated(object sender, Spaanjaars.Toolkit.RateEventArgs e)
{
  using (RatingDataContext db = new RatingDataContext())
  {
    // Execute the stored procedure that updates the rating
    db.UpdateRating(ContentId, e.RateValue);
    BindData(ContentId);
  }
}

That's pretty much it; the remainder of the code in the page is there for debugging purposes; it allows you to simulate a new Content ID, look at your existing cookies, and delete those cookies again so you can rate an existing item a second time.

I hope this article and the updated control sheds some light on the control's intended usage. I am sure many of you will appreciate the bug fixes..... ;-)

If you have questions, remarks, or found another bug: please let me know.

Download Files


Where to Next?

Wonder where to go next? You can post a comment on this article.

Doc ID 465
Full URL https://imar.spaanjaars.com/465/spaanjaarstoolkitcontentrating-version-11
Short cut https://imar.spaanjaars.com/465/
Written by Imar Spaanjaars
Date Posted 07/16/2008 20:29

Comments

Talk Back! Comment on Imar.Spaanjaars.Com

I am interested in what you have to say about this article. Feel free to post any comments, remarks or questions you may have about this article. The Talk Back feature is not meant for technical questions that are not directly related to this article. So, a post like "Hey, can you tell me how I can upload files to a MySQL database in PHP?" is likely to be removed. Also spam and unrealistic job offers will be deleted immediately.

When you post a comment, you have to provide your name and the comment. Your e-mail address is optional and you only need to provide it if you want me to contact you. It will not be displayed along with your comment. I got sick and tired of the comment spam I was receiving, so I have protected this page with a simple calculation exercise. This means that if you want to leave a comment, you'll need to complete the calculation before you hit the Post Comment button.

If you want to object to a comment made by another visitor, be sure to contact me and I'll look into it ASAP. Don't forget to mention the page link, or the Doc ID of the document.

(Plain text only; no HTML or code that looks like HTML or XML. In other words, don't use < and >. Also no links allowed.