Iteration E1: Creating a Smarter Cart

Associating a count with each product in our cart is going to require us to modify the line_items table. We’ve used migrations before; for example, we used a migration in Applying the Migration to update the schema of the database. While that was as part of creating the initial scaffolding for a model, the basic approach is the same:

 depot>​​ ​​bin/rails​​ ​​generate​​ ​​migration​​ ​​add_quantity_to_line_items​​ ​​quantity:integer

Rails can tell from the name of the migration that you’re adding columns to the line_items table and can pick up the names and data types for each column from the last argument. The two patterns that Rails matches on are AddXXXToTABLE and RemoveXXXFromTABLE, where the value of XXX is ignored; what matters is the list of column names and types that appear after the migration name.

The only thing Rails can’t tell is what a reasonable default is for this column. In many cases, a null value would do, but let’s make it the value 1 for existing carts by modifying the migration before we apply it:

rails51/depot_g/db/migrate/20170425000004_add_quantity_to_line_items.rb
 class​ AddQuantityToLineItems < ActiveRecord::Migration[5.1]
 def​ change
» add_column ​:line_items​, ​:quantity​, ​:integer​, ​default: ​1
 end
 end

Once it’s complete, we run the migration:

 depot>​​ ​​bin/rails​​ ​​db:migrate

Now we need a smart add_product method in our Cart, one that checks if our list of items already includes the product we’re adding; if it does, it bumps the quantity, and if it doesn’t, it builds a new LineItem:

rails51/depot_g/app/models/cart.rb
 def​ add_product(product)
  current_item = line_items.find_by(​product_id: ​product.id)
 if​ current_item
  current_item.quantity += 1
 else
  current_item = line_items.build(​product_id: ​product.id)
 end
  current_item
 end

The find_by method is a streamlined version of the where method. Instead of returning an array of results, it returns either an existing LineItem or nil.

We also need to modify the line item controller to use this method:

rails51/depot_g/app/controllers/line_items_controller.rb
 def​ create
  product = Product.find(params[​:product_id​])
» @line_item = @cart.add_product(product)
 
  respond_to ​do​ |format|
 if​ @line_item.save
  format.html { redirect_to @line_item.cart,
 notice: ​​'Line item was successfully created.'​ }
  format.json { render ​:show​,
 status: :created​, ​location: ​@line_item }
 else
  format.html { render ​:new​ }
  format.json { render ​json: ​@line_item.errors,
 status: :unprocessable_entity​ }
 end
 end
 end

We make one last quick change to the show view to use this new information:

rails51/depot_g/app/views/carts/show.html.erb
 <%​ ​if​ notice ​%>
  <aside id=​"notice"​>​<%=​ notice ​%>​</aside>
 <%​ ​end​ ​%>
 
 <h2>Your Pragmatic Cart</h2>
 <ul>
 <%​ @cart.line_items.each ​do​ |item| ​%>
» <li>​<%=​ item.quantity ​%>​ &times; ​<%=​ item.product.title ​%>​</li>
 <%​ ​end​ ​%>
 </ul>

Now that all the pieces are in place, we can go back to the store page and click the Add to Cart button for a product that’s already in the cart. What we’re likely to see is a mixture of individual products listed separately and a single product listed with a quantity of two. This is because we added a quantity of one to existing columns instead of collapsing multiple rows when possible. What we need to do next is migrate the data.

We start by creating a migration:

 depot>​​ ​​bin/rails​​ ​​generate​​ ​​migration​​ ​​combine_items_in_cart

This time, Rails can’t infer what we’re trying to do, so we can’t rely on the generated change method. What we need to do instead is to replace this method with separate up and down methods. First, here’s the up method:

rails51/depot_g/db/migrate/20170425000005_combine_items_in_cart.rb
 def​ up
 # replace multiple items for a single product in a cart with a
 # single item
  Cart.all.each ​do​ |cart|
 # count the number of each product in the cart
  sums = cart.line_items.group(​:product_id​).sum(​:quantity​)
 
  sums.each ​do​ |product_id, quantity|
 if​ quantity > 1
 # remove individual items
  cart.line_items.where(​product_id: ​product_id).delete_all
 
 # replace with a single item
  item = cart.line_items.build(​product_id: ​product_id)
  item.quantity = quantity
  item.save!
 end
 end
 end
 end

This is easily the most extensive code you’ve seen so far. Let’s look at it in small pieces:

Note how easily and elegantly Rails enables you to express this algorithm.

With this code in place, we apply this migration like any other migration:

 depot>​​ ​​bin/rails​​ ​​db:migrate

We can see the results by looking at the cart, shown in the following screenshot.

 

images/g_1_cart_2_quantities.png

 

Although we have reason to be pleased with ourselves, we’re not done yet. An important principle of migrations is that each step needs to be reversible, so we implement a down too. This method finds line items with a quantity of greater than one; adds new line items for this cart and product, each with a quantity of one; and, finally, deletes the line item:

rails51/depot_g/db/migrate/20170425000005_combine_items_in_cart.rb
 def​ down
 # split items with quantity>1 into multiple items
  LineItem.where(​"quantity>1"​).each ​do​ |line_item|
 # add individual items
  line_item.quantity.times ​do
  LineItem.create(
 cart_id: ​line_item.cart_id,
 product_id: ​line_item.product_id,
 quantity: ​1
  )
 end
 
 # remove original item
  line_item.destroy
 end
 end

Now, we can just as easily roll back our migration with a single command:

 depot>​​ ​​bin/rails​​ ​​db:rollback

Rails provides a Rake task to allow you to check the status of your migrations:

 depot>​​ ​​bin/rails​​ ​​db:migrate:status
 database: /home/rubys/work/depot/db/development.sqlite3
 
  Status Migration ID Migration Name
  --------------------------------------------------
  up 20160407000001 Create products
  up 20160407000002 Create carts
  up 20160407000003 Create line items
  up 20160407000004 Add quantity to line items
  down 20160407000005 Combine items in cart

Now, we can modify and reapply the migration or even delete it entirely. To inspect the results of the rollback, we have to move the migration file out of the way so Rails doesn’t think it should apply it. You can do that via mv, for example. If you do that, the cart should look like the following screenshot:

images/g_2_cart_2_no_quantities.png

Once we move the migration file back and reapply the migration (with the bin/rails db:migrate command), we have a cart that maintains a count for each of the products it holds, and we have a view that displays that count.

Since we changed the output the application produces, we need to update the tests to match. Note that what the user sees isn’t the string &times; but the Unicode character ×. If you can’t find a way to enter that character using your keyboard and operating system combination, you can use the escape sequence \u00D7[52] instead (also note the use of double quotes, as this is needed in Ruby to enter the escape sequence):

rails51/depot_h/test/controllers/line_items_controller_test.rb
  test ​"should create line_item"​ ​do
  assert_difference(​'LineItem.count'​) ​do
  post line_items_url, ​params: ​{ ​product_id: ​products(​:ruby​).id }
 end
 
  follow_redirect!
 
  assert_select ​'h2'​, ​'Your Pragmatic Cart'
» assert_select ​'li'​, ​"1 ​​\u​​00D7 Programming Ruby 1.9"
 end

Happy that we have something presentable, we call our customer over and show her the result of our morning’s work. She’s pleased—she can see the site starting to come together. However, she’s also troubled, having just read an article in the trade press on the way ecommerce sites are being attacked and compromised daily. She read that one kind of attack involves feeding requests with bad parameters into web applications, hoping to expose bugs and security flaws. She noticed that the link to the cart looks like carts/nnn, where nnn is our internal cart ID. Feeling malicious, she manually types this request into a browser, giving it a cart ID of wibble. She’s not impressed when our application displays the page shown in the screenshot.

images/g_3_cart_error.png

This seems fairly unprofessional. So, our next iteration will be spent making the application more resilient.