Sessions and Shopping Carts

Managing shopping carts using Sessions is the source of much confusion for beginners to ASP.NET. This article looks at how to do this so that items can be added and removed by customers easily before they commit to their purchase.

For this exercise, I am using ASP.NET Web Forms and the Northwind sample database. I'm going to present the user with a selection of products filtered by category. When the user changes the category, a postback will occur, which means that the products selected so far will be lost unless some kind of state management mechanism is employed. Session is a simple mechanism to use, but needs to be used judiciously to ensure that not too much server memory is used per user. All I'm going to store, therefore, is the ID of the selected products. This two-page sample will feature a ShoppingCart.aspx page, which includes a DropDownList of Categories to filter the porducts by, and a GridView listing the products associated with the selected Category. The user will be able to select products using CheckBoxes in the GridView, and then move to Checkout.aspx, where all their currently selected products will be displayed. At this point, they will also see the total cost of their order, and have the option to remove products from the basket if they so wish. I have used Visual Studio 2010, and the sample project is available for download at the end of the article.

The markup for ShoppingCart.aspx follows. There's nothing too fancy about this. You should notice that the GridView has its DataKeyNames property set to ProductID and that the CheckBoxes have been applied in a TemplateField. There are two buttons: one to add items to the shopping cart, and the other to move to the Checkout.aspx page.


<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" 
			CodeBehind="ShoppingCart.aspx.cs" Inherits="SessionShoppingCart.ShoppingCart" %>
<asp:Content ID="Content1" ContentPlaceHolderID="HeadContent" runat="server">
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
   <p>
      Select Category:
      <asp:DropDownList ID="Categories" runat="server" DataSourceID="CategoriesData" 
        DataTextField="CategoryName" DataValueField="CategoryID" AutoPostBack="true" />
      <asp:SqlDataSource ID="CategoriesData" runat="server" 
        ConnectionString="<%$ ConnectionStrings:NorthwindConnectionString %>"
        SelectCommand="SELECT [CategoryID], [CategoryName] FROM [Categories]"></asp:SqlDataSource>
    </p>
    <asp:GridView ID="Products" runat="server" AutoGenerateColumns="False" DataKeyNames="ProductID"
      DataSourceID="ProductsData" GridLines="None" EnableViewState="False">
      <Columns>
        <asp:TemplateField HeaderText="Add To Cart">
          <ItemTemplate>
            <asp:CheckBox ID="SelectedProducts" runat="server" />
          </ItemTemplate>
        </asp:TemplateField>
        <asp:BoundField DataField="ProductName" HeaderText="Product" SortExpression="ProductName" />
        <asp:BoundField DataField="CategoryName" HeaderText="Category" SortExpression="CategoryName" />
        <asp:BoundField DataField="UnitPrice" HeaderText="Price" SortExpression="UnitPrice"
          DataFormatString="{0:c}" />
      </Columns>
    </asp:GridView>
    <asp:Button ID="AddToCart" runat="server" Text="Select Products" OnClick="AddToCart_Click" /> 
    &nbsp;&nbsp;&nbsp;&nbsp;
    <asp:Button ID="Checkout" runat="server" Text="Check Out" OnClick="Checkout_Click" />
    <asp:SqlDataSource ID="ProductsData" runat="server" 
      ConnectionString="<%$ ConnectionStrings:NorthwindConnectionString %>"
      SelectCommand="SELECT Products.ProductID, Products.ProductName, Categories.CategoryName, 
        Products.UnitPrice FROM Products INNER JOIN Categories ON Products.CategoryID = Categories.CategoryID 
        WHERE Products.CategoryID = @CategoryID">
      <SelectParameters>
        <asp:ControlParameter ControlID="Categories" Name="CategoryID" PropertyName="SelectedValue"
          DefaultValue="1" />
      </SelectParameters>
    </asp:SqlDataSource>
</asp:Content>


When rendered, the page looks like this:

Both of the buttons have been wired up to Click event handlers in the code-behind. The first, AddToCart_Click, takes care of creating the shopping cart in session, and adding selected items to it:


protected void AddToCart_Click(object sender, EventArgs e)
{
  var selectedProducts = Products.Rows.Cast<GridViewRow>()
    .Where(row => ((CheckBox)row.FindControl("SelectedProducts")).Checked)
    .Select(row => Products.DataKeys[row.RowIndex].Value.ToString()).ToList();
  if (Session["Cart"] == null)
  {
    Session["Cart"] = selectedProducts;
  }
  else
  {
    var cart = (List<string>)Session["Cart"];
    foreach (var product in selectedProducts)
      cart.Add(product);
    Session["Cart"] = cart;
  }
  foreach (GridViewRow row in Products.Rows)
  {
    CheckBox cb = (CheckBox)row.FindControl("SelectedProducts");
    if (cb.Checked)
      cb.Checked = false;
  }
}

The first line is a Linq query (broken over 3 lines for clarity), which examines the GridViewRows collection of the GridView (Products) and obtains the DataKey value for each row that contains a ticked CheckBox. These are converted to a List<string>. If no products have been selected previously, Session["Cart"] will not have been created yet, and will be null. In that case, the List<string> (selectedProducts) will be assigned to the session variable Session["Cart"], which gets created automatically at that point. If products have already been selected and added to the cart (ie it alredy exists), it is retrieved form the session variable, and the new products are added to it, before the amended List<string> is re-assigned to the session variable. Finally, all the selected Checkboxes are cleared to prevent them being checked when the ShoppingCart page reappears.

The second Click event, tied to the Checkout button is much less eventful (excuse the pun):


protected void Checkout_Click(object sender, EventArgs e)
{
  if (Session["Cart"] != null)
    Response.Redirect("Checkout.aspx");
}

If the cart is not null, the user is redirected to the Checkout page.

Before going through the code for the Checkout page, here's how it looks if a few products have been selected:

You can see that the selected items appear as described earlier, with a CheckBox that allows removal of individual items. The total cost also appears. Here's the mark-up for Checkout.aspx:


<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true"
  CodeBehind="Checkout.aspx.cs" Inherits="SessionShoppingCart.Checkout" %>

<asp:Content ID="Content1" ContentPlaceHolderID="HeadContent" runat="server">
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
  <asp:GridView ID="Basket" runat="server" AutoGenerateColumns="False" 
    GridLines="None" EnableViewState="False" ShowFooter="True" 
    DataKeyNames="ProductID" OnRowCreated="Basket_RowCreated">
    <Columns>
      <asp:TemplateField HeaderText="Remove">
        <ItemTemplate>
          <asp:CheckBox ID="RemovedProducts" runat="server" />
        </ItemTemplate>
      </asp:TemplateField>
      <asp:TemplateField HeaderText="Product" SortExpression="ProductName">
        <ItemTemplate>
          <asp:Label ID="ProductName" runat="server" Text='<%# Eval("ProductName") %>' />
        </ItemTemplate>
        <FooterTemplate>
          <strong>
            Total Price:
          </strong>
        </FooterTemplate>
      </asp:TemplateField>
      <asp:TemplateField HeaderText="Price" SortExpression="UnitPrice">
        <ItemTemplate>
          <asp:Label ID="UnitPrice" runat="server" Text='<%# Eval("UnitPrice", "{0:c}") %>' />
        </ItemTemplate>
        <FooterTemplate>
          <strong>
            <asp:Literal ID="TotalPrice" runat="server" />
          </strong>
        </FooterTemplate>
      </asp:TemplateField>
    </Columns>
  </asp:GridView>
  <asp:Button ID="RemoveProduct" runat="server" 
    Text="Remove From Basket" OnClick="RemoveProduct_Click" />
  &nbsp;&nbsp;&nbsp;&nbsp;
  <asp:Button ID="ConfirmPurchase" runat="server" Text="Confirm Purchase" />
  <asp:SqlDataSource ID="BasketData" runat="server" 
    ConnectionString="<%$ ConnectionStrings:NorthwindConnectionString %>">
  </asp:SqlDataSource>
</asp:Content>



All the colums have been converted to TemplateFields in this case. This is so that I can add a footer row to the ProductName column and the UnitPrice column where I display the Total Price of the order. You should also notice that an event hanlder has been created for the OnRowCreated event, which is where the footer data is displayed. We'll see that in a minute. There are again, 2 buttons. One allows the user to remove items, and the second, well, it doesn't do anything in this example. But if you were to implement a full blown cart, it could take the user to the payment page of your site.

I'm going to post the entire code-behind for this form, and explain it afterwards:


public partial class Checkout : Page
{
  Decimal cost;
  protected void Page_Load(object sender, EventArgs e)
  {
    if (Session["Cart"] == null)
      Response.Redirect("ShoppingCart.aspx");
    BindBasket();
  }

  protected void RemoveProduct_Click(object sender, EventArgs e)
  {
    var cart = (List<string>)Session["Cart"];
    var removedProducts = Basket.Rows.Cast<GridViewRow>()
      .Where(row => ((CheckBox)row.FindControl("RemovedProducts")).Checked)
      .Select(row => Basket.DataKeys[row.RowIndex].Value.ToString()).ToList();
    cart.RemoveAll(removedProducts.Contains);
    BindBasket();
  }

  protected void BindBasket()
  {
    var sql = "SELECT ProductID, ProductName, UnitPrice FROM Products WHERE ProductID IN ({0})";
    var values = (List<string>)Session["Cart"];
    if (values.Count > 0)
    {
      var parms = values.Select((s, i) => "@p" + i.ToString()).ToArray();
      var inclause = string.Join(",", parms);
      BasketData.SelectCommand = string.Format(sql, inclause);
      BasketData.SelectParameters.Clear();
      for (var i = 0; i < parms.Length; i++)
      {
        BasketData.SelectParameters.Add(parms[i].Replace("@", ""), values[i]);
      }

      DataView view = (DataView)BasketData.Select(DataSourceSelectArguments.Empty);

      var costQuery = view.Cast<DataRowView>().Select(drv => drv.Row.Field<decimal>("UnitPrice"));
      cost = costQuery.Sum();
      Basket.DataSource = view;
      Basket.DataBind();
    }
  }

  protected void Basket_RowCreated(object sender, GridViewRowEventArgs e)
  {
    if (e.Row.RowType == DataControlRowType.Footer)
    {
      Literal total = (Literal)e.Row.FindControl("TotalPrice");
      total.Text = cost.ToString("c");
    }
  }
}

At the top of the code, a variable, Decimal cost is created. This will be used ot hold the total cost of the selected items. Within the Page_Load method, a check is made to see if Session["Cart"] exists yet. If not, the user is redirected to the ShoppingCart page. Well, they haven't chosen anything yet! Finally, a call to a local method, BindBasket() is made, which may look at first glance to be quite complicated. It's not, actually.

If you look at the SQL which appears in the first line of BindBasket, you will notice that it makes use of an IN clause: WHERE ProductID IN ({0}). If you are unfamiliar with this approach, have a look at my article on parameterised IN clauses, but essentially, an IN clause will accept a comma-separated string of values, and then find all items in the database table that meet any of those values in the WHERE clause. The next several lines of code extract the ProductIDs from the session-based shopping cart, and construct a parameterised SelectCommand for the SqlDataSource and set up a collection of SelectParameters. The article on parameterised IN clauses will help you understand this in depth.

Then I fire the SqlDataSource's SelectCommand and obtain a DataView. You can read more about that in this article on obtaining values from SqlDataSources. Once I have the DataView, I again use LINQ to query it for for the UnitPrice values and total them up using Sum(). As you may have noticed by now, LINQ is not all about querying SQL Server databases. A good introduction to LINQ To DataSet can be found here. The total of the UnitPrices is stored in the variable that was declared at the top of the page. Finally, within the BindBasket() method, the DataView is bound to the GridView.

The Basket_RowCreated event handler identifies the footer, and applies the total cost value to the Literal control within it.

The RemoveProduct_Click event handler is responsible for deleting products that are no longer wanted. Again, it may look more complicated at first glance than it really is. However, it's just another LINQ query to obtain the rows that were checked, and then the these are removed from the List<string> that comes from the cart. Once this has been accomplished, the databinding method is called again so that the user can see the refreshed basket.

The code is available for download, but you probably appreciate that this is not a finished application. Apart from the fact that there is no payment page, there is no way for users to purchase more than one of each item. You may also have a distinct dislike for SqlDataSources, which I used in this example purely for speed and convenience. You are free to modify the code as you see fit, or just try to draw some modicum of inspiration from it.