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:
| 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:
| 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:
| 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:
| <% if notice %> |
| <aside id="notice"><%= notice %></aside> |
| <% end %> |
| |
| <h2>Your Pragmatic Cart</h2> |
| <ul> |
| <% @cart.line_items.each do |item| %> |
» | <li><%= item.quantity %> × <%= 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:
| 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:
We start by iterating over each cart.
For each cart, we get a sum of the quantity fields for each of the line items associated with this cart, grouped by product_id. The resulting sums will be a list of ordered pairs of product_ids and quantity.
We iterate over these sums, extracting the product_id and quantity from each.
In cases where the quantity is greater than one, we delete all of the individual line items associated with this cart and this product and replace them with a single line item with the correct quantity.
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.
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:
| 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:
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 × 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):
| 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 \u00D7 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.
This seems fairly unprofessional. So, our next iteration will be spent making the application more resilient.