Author Archives: Phở Code

Rails – Tùy chỉnh giỏ hàng

Chúng ta sẽ thêm nút xóa giỏ hàng, hiển thị tổng tiền cho giỏ hàng.

Thêm nút xóa giỏ hàng

Đầu tiên chúng ta sửa lại file View show.html.erb trong thư mục app/views/carts như sau:

<%= notice %>
<h2>Your cart</h2>
<ul>
    <% @cart.line_items.each do |a| do %>
        <li><%= a.quantity %> x <%= a.product.title %></li>
    <% end %>
</ul>

<%= button_to 'Empty cart', 
              @cart, 
              :method => :delete,
              data: {:confirm => 'Are you sure?'} %>

Chúng ta gọi phương thức button_to để tạo một nút bấm với nhãn là ‘Empty cart’, URL chúng ta lấy từ biến @cart, nếu muốn bạn vẫn có thể dùng các phương thức như cart_path(), phương thức gửi lên là DELETE, tham số :data với thuộc tính :confirm sẽ tạo ra một hộp thoại như hàm alert() trong Javascript nếu bạn còn nhớ.

Ngoài ra chúng ta cũng đã xóa 2 nút EditBack do Rails tự tạo nếu bạn còn nhớ:

<%= link_to 'Edit', edit_cart_path(@cart) %> |
<%= link_to 'Back', carts_path %>

Nút ‘Empty cart’ sẽ gọi đến phương thức destroy trong lớp controller CartsController, trong trang /cart Rails cũng đã tạo cho chúng ta một nút như vậy với nhãn là ‘Destroy’ (nếu muốn bạn có thể vào xem thử), và mặc định phương thức destroy trong lớp Controller sẽ xóa giỏ hàng với id được truyền từ tham số, tuy nhiên ở đây chúng ta muốn phương thức này chỉ xóa giỏ hàng nào đang được lưu trong session của trình duyệt thôi, do đó chúng ta sửa lại phương thức destroy này như sau:

class CartsController < ApplicationController 
    .
    .
    .
    # DELETE /carts/1
    # DELETE /carts/1.json
    def destroy 
        @cart = current_cart
        @cart.destroy
        session[:cart_id] = nil
 
        respond_to do |format|
            format.html { redirect_to carts_url, notice: 'Cart was successfully destroyed.' }
            format.json { head :no_content }
        end
    end
    .
    .
    .
end

Chúng ta lấy model Cart hiện tại bằng phương thức current_cart đã được định nghĩa trong lớp ApplicationController, sau đó gọi hàm destroy để xóa, cuối cùng gán giá trị trong sessionnil.

Hiển thị tổng số tiền trong giỏ hàng

Chúng ta sửa lại file View show.html.erb một lần nữa như sau:

<%= notice %>
<h2>Your cart</h2>
<table>
    <% @cart.line_items.each do |item| %>
        <tr>
            <td><%= item.quantity %> x </td>
            <td><%= item.product.title %></td>
            <td class="item_price"><%= number_to_currency(item.total_price) %></td>
        </tr>
    <% end %>
    
    <tr class="total_line">
        <td colspan="2">Total</td>
        <td class="total_cell"><%= number_to_currency(@cart.total_price) %></td>
    </tr>
</table>

<%= button_to 'Empty cart', 
              @cart, 
              :method => :delete,
              data: {:confirm => 'Are you sure?'} %>

Ở đây thay vì dùng các thẻ <ul>, <li> để hiển thị danh sách thì chúng ta sẽ dùng bảng. Giá trị của tổng số tiền trên từng LineItem và tổng số tiền của Cart sẽ lấy từ phương thức total_price của lớp model tương ứng.

Các thẻ trên cũng dùng một số lớp CSS riêng, chúng ta định nghĩa các lớp CSS mới như sau:

.
.
.
#store .cart_title {
    font: 120% bold;
}

#store .item_price, #store .total_line {
    text-align: right;
}

#store .total_line .total_cell {
    font-weight: bold;
    border-top: 1px solid #595;
}

Và bây giờ chúng ta sẽ định nghĩa phương thức total_price cho từng model, đầu tiên chúng ta định nghĩa cho lớp LineItem như sau:

class LineItem < ActiveRecord::Base
    belongs_to :product
    belongs_to :cart
 
    def total_price
        product.price * quantity
    end
end

Rất đơn giản, số tiền bằng tổng số sản phẩm nhân với đơn giá sản phẩm.

Tiếp theo là lớp Cart:

class Cart < ActiveRecord::Base 
    has_many :line_items, :dependent => :destroy
 
    def add_product(product_id)
        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
 
    def total_price 
        line_items.to_a.sum { |item| item.total_price }
    end
end

Ở đây số tiền sẽ bằng tổng số tiền của các đối tượng LineItem cộng lại. Phương thức to_a sẽ chuyển một đối tượng danh sách sang kiểu mảng, sau đó chúng ta gọi phương thức sum của mảng đó.

Bạn có thể xem tổng tiền và có thể xóa giỏ hàng được rồi:

capture

Ngoài ra nếu trước khi làm chức năng session mà bạn đã từng tạo nhiều giỏ hàng thì trong trang /carts (liệt kê danh sách giỏ hàng), trang này gọi đến phương thức index trong lớp CartsController, chúng ta sẽ thấy có nhiều dòng giỏ hàng, đây là trang hiển thị mặc định của Rails mà đến bây giờ chúng ta vẫn chưa chỉnh sửa gì trong này.

capture

Trang này có hiển thị nút ‘Destroy’ để bạn xóa giỏ hàng, tuy nhiên sau khi làm chức năng chỉ xóa giỏ hàng hiện tại ở trên thì bạn chỉ có thể xóa một giỏ hàng trong danh sách đó thôi, mặc dù nếu muốn bạn vẫn có thể bấm nút xóa trên các giỏ hàng khác và Rails vẫn thông báo xóa thành công, nhưng khi load lại trang thì bạn vẫn thấy các giỏ hàng đó tồn tại. Chúng ta sửa lại để hàm này chỉ hiển thị giỏ hàng trong session của trình duyệt như sau:

class CartsController < ApplicationController
    .
    .
    .
    # GET /carts
    # GET /carts.json
    def index  
        @carts = []
        if session[:cart_id] != nil 
            @carts << Cart.find_by_id(session[:cart_id])
        end
    end
    .
    .
    .
end

Vậy là xong, bây giờ nếu bạn mở 2 trình duyệt hoặc mở 2 máy khác nhau, và mỗi trình duyệt đều có giỏ hàng riêng, và cùng trỏ đến trang /carts thì bạn chỉ thấy giỏ hàng của từng trình duyệt đó chứ không thấy giỏ hàng của trình duyệt khác.

Rails – Migration

Trong bài trước chúng ta đã xây dựng tính năng giỏ hàng, tuy nhiên chưa xử lý được thông minh lắm, giả sử bạn bấm nút ‘Add to Cart’ của một sản phẩm nhiều lần, thì khi hiển thị trên trang /carts/<id>, danh sách các sản phẩm được hiển thị ra hết trên từng dòng, có 100 sản phẩm thì có 100 dòng, cho dù chúng cùng là một thứ, bây giờ chúng ta sẽ thiết kế để in số lượng sản phẩm ngay bên cạnh tên sản phẩm luôn, chứ không cần phải in trên nhiều dòng như thế.

Để làm việc này thì chúng ta cần thêm một cột/trường mới để lưu số lượng sản phẩm trong model/bảng LineItem. Tuy nhiên việc thay thế cấu trúc các bảng trong CSDL như thêm, xóa, sửa cột sẽ gây ra nhiều rắc rối (đặc biệt là các cột khóa ngoại, khóa chính…v.v), chúng ta có thể “bảo” Rails thay thế giùm chúng ta bằng tính năng Migration của Rails.

Mỗi khi chạy migration, Rails sẽ tạo ra một phiên bản CSDL mới dựa vào các migration mà chúng ta đã tạo ra và cập nhật lại.

Migration trong Rails là các file .rb có tên tương tự như <YYYYMMDDHHMMSS>_create_products.rb, bên trong là phần định nghĩa một lớp có tên như CreateProducts được kế thừa từ lớp ActiveRecord::Migration, và một phương thức có tên là change dùng để thêm/xóa/sửa cột trong bảng. Phần YYYYMMDDHHSS là thời gian file này được tạo ra, Rails sẽ dựa vào chuỗi này để biết phần nào được cập nhật trước.

Ví dụ chúng ta thực hiện việc thêm trường quantity dùng để lưu số lượng sản phẩm trong bảng line_items bằng cách chạy lệnh rails generate migration như sau:

C:\Projects\Rails\depot>rails generate migration add_quantity_to_line_items quantity:integer
    invoke active_record
    create   db/migrate/20161123043154_add_quantity_to_line_items.rb

Chúng ta tạo một migration có tên add_quantity_to_line_items, Rails sẽ tạo một file có tên dạng như 20161123043154_add_quantity_to_line_items.rb trong thư mục db/migrate, bên trong là định nghĩa lớp AddQuantityToLineItems. Nếu lớp migration của bạn có tên dạng như Add<X>To<Y> hoặc Remove<X>From<Y> thì Rails sẽ tự động hiểu là tạo thêm trường X vào bảng Y hoặc xóa trường X trong bảng Y, và tự động thêm các đoạn code thích hợp trong phương thức change.

Ở đây Rails sẽ hiểu là thêm một trường có tên là quantity vào bảng line_items, và tự biết là trường này có kiểu dữ liệu là INTEGER do chúng ta truyền vào trong lệnh generate migration ở trên. Nội dung của file 20161123043154_add_quantity_to_line_items.rb hiện tại như sau:

class AddQuantityToLineItems < ActiveRecord::Migration
    def change
        add_column :line_items, :quantity, :integer
    end 
end

Bên trong phương thức change chỉ có một dòng duy nhất là lời gọi phương thức add_column, bạn có thể tự biết đây là phương thức thêm một cột mới trong bảng, thêm số đầu tiên là tên bảng, thâm số thứ 2 là tên cột, tham số thứ 3 là kiểu dữ liệu, các kiểu dữ liệu khác có thể đưa vào là :string, :text, :float, :decimal, :datetime, :timestamp, :time, :date, :binary, :boolean. Ngoài ra còn có một tham số thứ 4 nữa mà chúng ta có thể đưa vào nếu muốn, đây là tham số tùy chọn, chúng ta sửa lại để cột này nhận giá trị mặc định là 1 như sau:

class AddQuantityToLineItems < ActiveRecord::Migration 
    def change 
        add_column :line_items, :quantity, :integer, :default => 1
    end
end

Các tham số tùy chọn khác có thể đưa vào là :limit (giới hạn) và :null (cột có được phép để NULL hay không), khi truyền nhiều tham số tùy chọn thì chúng ta truyền vào kiểu bảng băm ví dụ như: { :limit => 50, :null => false, :default => 1 }.

Ngoài ra còn có một số phương thức khác như:

  • create_table(name, options): tạo bảng với tên là name
  • drop_table(name): xóa bảng với tên là name
  • change_table(name, options): thay đổi cấu trúc bảng name
  • rename_table(oldname, newname): đổi tên bảng oldname thành newname
  • rename_column(table, oldname, newname): đổi tên cột oldname thành newname trong bảng table
  • change_column(table, column, options): thay đổi cột column trong bảng table
  • remove_column(table, column, type, options): xóa cột column khỏi bảng table

Để thực hiện thay đổi CSDL thì chúng ta chạy lệnh rake db:migrate là được

C:\Projects\Rails\depot>rake db:migrate
== 20161123043154 AddQuantityToLineItems: migrating ===================================
-- add_column(:line_items, :quantity, :integer, {:default=>1})
   -> 0.0205s
== 20161123043154 AddQuantityToLineItems: migrated (0.0216s) ==========================

Bây giờ chúng ta cần chỉnh sửa lại cách model tạo giỏ hàng cho đúng với CSDL mới.

Đầu tiên chúng ta sửa lại file cart.rb trong thư mục app/models như sau.

class Cart < ActiveRecord::Base 
    has_many :line_items, :dependent => :destroy
 
    def add_product(product_id)
        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
end

Chúng ta định nghĩa phương thức add_product nhận vào id của sản phẩm để tạo hoặc chỉnh sửa đối tượng LineItems. 

Đầu tiên chúng ta tìm xem trong dách sách các đối tượng LineItems (trong biến line_items) có đối tượng nào có product_id trùng với product_id trong tham số hay không bằng cách dùng phương thức find_by_product_id, phương thức này mặc định không có trong lớp ActiveRecord::Base, nhưng chúng ta vẫn có thể sử dụng được, đó là nhờ vào tính năng Dynamic Finders (đọc thêm ở đây) trong lớp này, theo đó thì trong model/bảng của bạn có thuộc tính nào thì bạn có thể gọi một phương thức có tên find_by_<tên cột>và truyền vào tham số là một giá trị có cùng kiểu dữ liệu với cột đó, ở đây chúng ta truyền vào một số nguyên là id của Product.

Sau đó nếu tìm thấy đối tượng LineItems nào thì chúng ta tăng giá trị của thuộc tính quantity lên 1, nếu không thì chúng ta tạo một đối tượng LineItems mới. Cuối cùng chúng ta trả về đối tượng LineItems đó.

Tiếp theo chúng ta sửa lại phương thức create của lớp controller trong file line_items_controller.rb như sau:

class LineItemsController < ApplicationControler
    .
    .
    .
    # POST /line_items
    # POST /line_items.json
    def create
        @cart = current_cart
        product = Product.find(params[:product_id])
        @line_item = @cart.add_product(product.id)

        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
    .
    .
    .
end

Đối tượng LineItems sẽ được tạo bằng phương thức add_product đã được định nghĩa ở trên.

Cuối cùng chúng ta sửa lại View để hiển thị số lượng như sau:

 
<%= notice %>
<h2>Your cart</h2>
<ul>
    <% @cart.line_items.each do |a| %>
    <li><%= a.quantity %> x <%= a.product.title %></li>
    <% end %>
</ul>
<%= link_to 'Edit', edit_cart_path(@cart) %> |
<%= link_to 'Back', carts_path %>

Vậy là xong, chúng ta có thể chạy được rồi.

capture

Rails – Tạo nút thêm giỏ hàng

Trong các bài trước chúng ta đã xây dựng cơ chế session và tạo mối quan hệ giữa Product và Cart, bây giờ chúng ta sẽ xây dựng chức năng tạo giỏ hàng.

Đầu tiên chúng ta sửa lại file index.html.erb trong thư mục app/views/store như sau:

<% if notice %>
    <p id="notice"><%= notice %></p>
<% end %>
 
<h1>Product List</h1>
 
<% @products.each do |product| %>
    <div class="entry">
        <%= image_tag(product.image_url) %>
        <h3><%= product.title %></h3> 
        <%= sanitize(product.description) %>
        <div class="price_line">
            <span class="price"><%= number_to_currency(product.price) %></span> 
            <%= button_to 'Add to Cart', line_items_path(:product_id => product) %>
        </div>
    </div>
<% end %>

Phương thức button_to sẽ tạo một thẻ <form></form> với class là button_to, bên trong có một thẻ <input> với thuộc tính typeSubmit, nhãn là tham số do chúng ta truyền vào, ở đây là chuỗi ‘Add to Cart’, phương thức mặc định là POST, URL chuyển đến là tham số thứ 2, ở đây là chuỗi được tạo ra từ phương thức helper line_items_path(), phương thức này được Rails định nghĩa tự động cho từng controller và sẽ trả về URL của lớp controller tương ứng, ví dụ như line_items_path() trả về /line_item, cart_path() trả về /cart…v.v Phương thức này còn có thể nhận vào tham số và tự gắn vào đuôi như URL bình thường, ở đây chúng ta định nghĩa tham số :product_id và truyền vào giá trị của biến product, tức là id của sản phẩm, và URL cuối cùng sẽ có dạng như /line_item?product_id=1, /line_item?product_id=2…v.v

Và khi chúng ta click vào nút đó thì một trong số các phương thức routing của lớp LineItemsController sẽ được gọi:

class LineItemsController < ApplicationController
    before_action :set_line_item, only: [:show, :edit, :update, :destroy]

    # GET /line_items
    # GET /line_items.json
    def index
        @line_items = LineItem.all
    end

    # GET /line_items/1
    # GET /line_items/1.json
    def show 
    end

    # GET /line_items/new
    def new    
    end

    # GET /line_items/1/edit
    def edit
    end

    # POST /line_items
    # POST /line_items.json
    def create   
        @line_item = LineItem.new(line_item_params)

        respond_to do |format|
            if @line_item.save
                format.html { redirect_to @line_item, 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

    # PATCH/PUT /line_items/1
    # PATCH/PUT /line_items/1.json
    def update
        respond_to do |format|
            if @line_item.update(line_item_params)
                format.html { redirect_to @line_item, notice: 'Line item was successfully updated.' }
                format.json { render :show, status: :ok, location: @line_item }
            else
                format.html { render :edit }
                format.json { render json: @line_item.errors, status: :unprocessable_entity }
            end
        end
    end

    # DELETE /line_items/1
    # DELETE /line_items/1.json
    def destroy
        @line_item.destroy
        respond_to do |format|
            format.html { redirect_to line_items_url, notice: 'Line item was successfully destroyed.' }
           format.json { head :no_content }
        end
    end

private
    # Use callbacks to share common setup or constraints between actions.
    def set_line_item
        @line_item = LineItem.find(params[:id])
    end

# Never trust parameters from the scary internet, only allow the white list through.
    def line_item_params
        params.require(:line_item).permit(:product_id, :cart_id)
    end
end

Có 2 phương thức có URL giống nhau (đều là /line_items) là indexcreate, tuy nhiên phương thức được gọi sẽ là create, bởi vì chỉ có create mới dùng phương thức POST. 

Tuy nhiên phương thức này đang chạy theo code mặc định của Rails, tức là tạo đối tượng LineItems được gửi lên từ form ở /line_items/new, bạn có thể trỏ vào dùng thử.

Chúng ta sửa lại phương thức create để lấy từ nút button do chúng ta định nghĩa ở trên như sau:

class LineItemsController < ApplicationControler
    .
    .
    .
    # POST /line_items
    # POST /line_items.json
    def create
        @cart = current_cart
        product = Product.find(params[:product_id])
        @line_item = @cart.line_items.build(: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
    .
    .
    .
end

Đầu tiên chúng ta tạo hoặc lấy một đối tượng Cart trong session của người dùng từ phương thức current_cart được định nghĩa trong lớp ApplicationController (application_controller.rb).

Sau đó chúng ta dùng id được truyền vào tham số :product_id để lấy đối tượng Product cụ thể, tham số :product_id được gửi vào biến params, đây là biến được tạo ra khi chúng ta bấm vào nút ‘Add to Cart’ đã định nghĩa ở trên.

Trong đối tượng @cart ở trên có đối tượng line_items, đây là một đối tượng lưu trữ các phần tử theo dạng danh sách thuộc lớp ActiveRecord::Associations::CollectionProxy, lớp này có một phương thức là build() dùng để tạo một phần tử cho danh sách của nó. Chúng ta gọi phương thức này để tạo một đối tượng LineItem mới, có tham số truyền vào là id của Product (:product_id) rồi lưu vào biến @line_item.

Tiếp theo là đoạn respond_to dùng để trả nội dung về cho người dùng, từ trước đến giờ chúng ta vẫn chưa tìm hiểu sâu về các đoạn respond_to cũng như đối tượng format, trong bài này chúng ta cũng sẽ chưa đi vào tìm hiểu làm gì, mình sẽ giải thích ở các bài sau, chỉ có một lưu ý là chúng ta gọi hàm redirect_to đến URL /cart/<id> dựa vào biến @line_item.cart, thay vì trả về /line_items.

Chúng ta có thể chạy thử được rồi, bấm vào một nút ‘Add to Cart’ để thêm một sản phầm vào giỏ hàng.

capture

Chúng ta có thể liệt kê danh sách các sản phẩm trong giỏ hàng ngay tại trang /cart/<id> đó luôn bằng cách sửa file show.html.erb trong thư mục app/views/carts như sau:

<p id="notice"><%= notice %></p>
<h2>Your cart</h2>
<ul>
    <% @cart.line_items.each do |a| %>
    <li><%= a.product.title %></li>
    <% end %>
</ul>
<%= link_to 'Edit', edit_cart_path(@cart) %> |
<%= link_to 'Back', carts_path %>

capture

Rails – Quan hệ giữa model

Nếu bạn đã từng học lý thuyết về cơ sở dữ liệu quan hệ thì bạn sẽ biết là các bảng trong một CSDL sẽ có thể có các mối quan hệ với nhau, như quan hệ 1-1, 1-n, n-n…v.v, nếu bạn chưa biết về lý thuyết quan hệ này thì tự tìm hiểu thêm, trong bài này mình không giải thích mà chỉ hướng dẫn cách mô tả các mối quan hệ này trong Rails.

Trong các bài trước chúng ta đã tạo một model là product dùng để lưu thông tin của từng sản phẩm, model cart có chức năng lưu thông tin của một giỏ hàng (mặc dù không có trường nào), bây giờ chúng ta sẽ tạo một model dùng để kết nối thông tin giữa sản phẩm và giỏ hàng.

Model đó sẽ có tên line_item, chúng ta tạo model như sau:

C:\Projects\Rails\depot>rails generate scaffold line_item product_id:integer cart_id:integer
...

Model này sẽ có 2 cột dùng để lưu id của cart và product là product_idcart_id.

Tiếp theo chúng ta chạy lệnh rake db:migrate để tạo bảng.

C:\Projects\Rails\depot>rake db:migrate
== 20161121012142 CreateLineItems: migrating ==========================================
-- create_table(:line_items)
   -> 0.0150s
== 20161121012142 CreateLineItems: migrated (0.0158s) =================================

Vậy là chúng ta đã tạo xong bảng trong CSDL và lớp model, tuy nhiên hiện tại model lại không biết gì về các quan hệ này, chúng ta phải gọi thêm một số phương thức nữa, các phương thức mà chúng ta sẽ dùng được gọi là Associations.

Chúng ta sửa file cart.rb trong thư mục app/models như sau:

class Cart < ActiveRecord::Base
    has_many :line_items, :dependent => :destroy
end

Phương thức has_many :line_items chỉ định một đối tượng Cart sẽ có nhiều liên kết tới đối tượng LineItem, tham số :dependent => :destroy cho biết khi một đối tượng Cart bị hủy thì đối tượng LineItem đó cũng sẽ bị hủy theo.

TIếp theo chúng ta sửa file model line_item.rb như sau:

class LineItem < ActiveRecord::Base
    belongs_to :product
    belongs_to :cart
end

Phương thức belongs_to cho biết một đối tượng LineItem sẽ “thuộc về” một đối tượng nào đó, ở đây là ProductCart. Phương thức này bắt buộc Rails phải “hiểu” rằng sẽ không bao giờ tồn tại một đối tượng LineItem nào mà không liên kết/thuộc về một đối tượng CartProduct nào đó.

Khi một đối tượng LineItem “thuộc về” một đối tượng ProductCart thì chúng ta có thể truy xuất các đối tượng ProductCart đó trong đối tượng LineItem.

Cuối cùng chúng ta sửa file model product.rb như sau:

class Product < ActiveRecord::Base
    validates :title, :description, :image_url, :presence => true
    validates :price, :numericality => {:greater_than_or_equal_to => 1.0}
    validates :title, :uniqueness => true
    validates :image_url, :format => {
        :with => %r{\.(gif|jpg|png)\Z}i,
        :message => 'Chi nhan file GIF, JPG, PNG'
    }
 
    has_many :line_items
    before_destroy :check_if_has_line_item
 
private
 
    def check_if_has_line_item
        if line_items.empty?
            return true
        else
            errors.add(:base, 'This product has a LineItem')
            return false
        end
    end
end

Tương tự với model Cart, một đối tượng Product cũng sẽ có nhiều liên kết tới một đối tượng LineItem.

before_destroy :check_if_has_line_item

Ngoài ra ở đây chúng ta còn sử dụng tới một hàm Callback của lớp ActiveRecord::Basebefore_destroy, hàm Callback ở đây là các hàm sẽ được gọi trước hoặc sau khi Rails thực hiện một thao tác có cập nhật lên CSDL, chẳng hạn như before_destroy tức là gọi một phương thức nào đó trước khi hủy một dòng trong bảng, ở đây là phương thức check_if_has_line_item do chúng ta tự định nghĩa, phương thức này chúng ta để phạm vi truy cập là private.

private
 
    def check_if_has_line_item
        if line_items.empty?
            return true
        else
            errors.add(:base, 'This product has a LineItem')
            return false
        end
    end

Phương thức này sẽ kiểm tra xem một đối tượng Product có liên kết nào tới một đối tượng LineItem nào không, nếu không thì trả về true, ngược lại thì chúng ta tạo một phần tử trong thuộc tính errors có khóa là :base và giá trị là 'This product has a LineItem', rồi trả về false.

Nếu phương thức ở before_destroy trả về false thì Rails sẽ không hủy đối tượng đó.

Bạn có thể tìm hiểu thêm về các hàm callback ở đây:

http://guides.rubyonrails.org/active_record_callbacks.html

Bạn có thể tìm hiểu thêm về các phương thức Associations ở đây:

http://guides.rubyonrails.org/association_basics.html

Rails – Session

Session là được hiểu 1 phiên làm việc trong đó người sử dụng giao tiếp với 1 ứng dụng. Session bắt đầu khi người sử dụng truy cập vào ứng dụng lần đầu tiên, và kết thúc khi người sử dụng thoát khỏi ứng dụng.

Khi người dùng xem các sản phẩm trên một website bán hàng, có thể họ sẽ muốn mua nó, do đó chúng ta sẽ xây dựng tính năng giỏ hàng để lưu trữ các sản phẩm mà khách hàng muốn mua trong session.

Chúng ta sẽ định nghĩa một model cart (giỏ hàng), mỗi khi có người dùng nào truy cập vào website thì chúng ta sẽ tạo một đối tượng cart rồi lưu vào CSDL. Khi người dùng thoát trang web, sau đó quay lại thì chúng ta sẽ kiểm tra trong session của họ có id của cart nào hay không, nếu có thì lấy ra hiển thị, không thì lại tạo mới như ban đầu.

Đầu tiên chúng ta tạo model như sau:

C:\Projects\Rails\depot>rails generate scaffold cart
...

Chúng ta không định nghĩa cột nào ở đây cả vì sau này chúng ta sẽ liên kết với các model khác là đủ.

Tiếp theo chúng ta chạy lệnh rake db:migrate để Rails tạo bảng carts trong CSDL:

C:\Projects\Rails\depot>rake db:migrate
== 20161119034946 CreateCarts: migrating ==============================================
-- create_table(:carts)
   -> 0.0182s
== 20161119034946 CreateCarts: migrated (0.0194s)======================================

Nếu bạn còn nhớ thì Rails sẽ tự động tạo 3 trường là id,  created_atupdated_at:

C:\Projects\Rails\depot\db>sqlite3 development.sqlite3
> pragma table_info(carts);
0|id|INTEGER|1|1
1|created_at|datetime|1|0
2|updated_at|datetime|1|0

Tất cả các lớp controller do Rails tạo ra đều được kế thừa từ lớp ApplicationController (trong file app/controllers/application_controller.rb), lớp này cũng là một lớp do Rails tự tạo ra và được kế thừa từ lớp ActionController::Base, trong mỗi lớp controller bất kỳ có một đối tượng toàn cục có tên là session thuộc lớp ActionDispatch::Request::Session, đây là một lớp lưu trữ dữ liệu theo dạng từ điển, tức là cứ mỗi phần tử trong này sẽ bao gồm một cặp <khóa>-<giá trị>.

Bây giờ chúng ta sửa lại file application_controller.rb như sau:

class ApplicationController < ActionController::Base
    # Prevent CSRF attacks by raising an exception.
    # For APIs, you may want to use :null_session instead.
    protect_from_forgery with: :exception
 
    private
    
    def current_cart
        Cart.find(session[:cart_id])
    rescue ActiveRecord::RecordNotFound
        cart = Cart.create
        session[:cart_id] = cart.id
        cart
    end
end

Ở đây chúng ta định nghĩa một phương thức có tên current_cart dùng để lấy thuộc tính id của đối tượng Cart trong đối tượng session của người dùng khi người dùng truy cập vào website. Chúng ta chỉ định phương thức này có phạm vi truy xuất là private.

Đầu tiên chúng ta tìm xem trong CSDL có giỏ hàng nào có id giống với phần tử có khóa là :cart_id trong session hay không bằng phương thức Cart.find(), nếu không có thì phương thức này sẽ giải phóng một exception là ActiveRecord::RecordNotFound, chúng ta bắt lỗi này và thực hiện tạo một đối tượng Cart mới, sau đó tạo một phần tử trong đối tượng session với khóa là :cart_id và giá trị là id mới vừa được tạo (thuộc tính idcart.id).

Ngoài các lớp controller thì một khu vực khác cũng có thể đọc/ghi dữ liệu với đối tượng session là View, chúng ta sẽ xử lý session với View trong các bài sau.

Rails – Functional Testing

Trong bài Unit Testing chúng ta đã tìm hiểu cách kiểm tra code đối với model, trong phần này chúng ta sẽ tìm hiểu cách kiểm tra code trên controller – hay còn gọi là Functional Testing.

Khi chúng ta tạo một controller thì Rails sẽ tự động tạo cho chúng ta một file dùng để test controller này, trong bài Routing chúng ta đã tạo một controller là store, và Rails sẽ tạo một file test tương ứng có tên là store_controller_test.rb trong thư mục test/controllers. Ngoài ra nếu bạn còn nhớ thì khi tạo một model, Rails cũng sẽ định nghĩa controller cho model này, và dĩ nhiên Rails cũng sẽ tạo một file test cho controller này, chúng ta đã tạo model Product và file test controller là file products_controller_test.rb cũng nằm trong thư mục test/controllers.

capture

Nội dung của file products_controller_test.rb như sau:

require 'test_helper'

class ProductsControllerTest < ActionController::TestCase
    setup do
        @product = products(:one) 
    end

    test "should get index" do
        get :index
        assert_response :success
        assert_not_nil assigns(:products)
    end

    test "should get new" do
        get :new
        assert_response :success
    end

    test "should create product" do
        assert_difference('Product.count') do 
             post :create, product: { 
                     description: @product.description, 
                     image_url: @product.image_url, 
                     price: @product.price, 
                     title: @product.title } 
        end

        assert_redirected_to product_path(assigns(:product))
    end

    test "should show product" do
         get :show, id: @product
         assert_response :success
    end

    test "should get edit" do
         get :edit, id: @product
         assert_response :success
    end

    test "should update product" do
         patch :update, id: @product, product: { 
                 description: @product.description, 
                 image_url: @product.image_url, 
                 price: @product.price, 
                 title: @product.title }   
         assert_redirected_to product_path(assigns(:product)) 
    end

    test "should destroy product" do
         assert_difference('Product.count', -1) do
             delete :destroy, id: @product
         end
 
         assert_redirected_to products_path
     end
end

Trong đó đoạn setup do...end sẽ làm công việc khởi tạo, ở đây Rails khởi tạo biến @product là đối tượng one trong fixture.

Sau đó là các đoạn code test, ở đây các phương thức get, post, patch sẽ gửi các gói tin HTTP lên server, theo sau là tên các hàm trong model mà Rails đã định nghĩa trước, như index là phương thức index trong controller, các phương thức gửi sẽ tương ứng với một phương thức của giao thức HTTP như GET, POST, PATCH, PUT… sau đó chúng ta dùng các phương thức assert để kiểm tra dữ liệu trả về từ server, trong đó:

  • assert_response: nhận vào tham số mã và kiểm tra xem gói tin trả về có mã giống với tham số hay không, ở đoạn code trên các đoạn assert_response đều kiểm tra với mã là :success, tương ứng với mã từ 200-299, ngoài ra còn có :redirect tương ứng với mã từ 300-399, :missing là mã 404, :error là từ 500-599.
  • assert_not_nil: nhận vào một đối tượng và kiểm tra xem đối tượng đó có giá trị là nil hay không
  • assert_difference: nhận vào tham số là một biểu thức (phép tính, hàm, toán tử…v.v) và tính với hiệu của biểu thức đó sau khi thực hiện phần code phía sau, rồi kiểm tra xem hiệu đó có bằng 1 hay không.
  • assert_redirected_to: nhận vào tham số là một URL, kiểm tra xem URL đó có giống với hành động chuyển hướng trang web cuối cùng được thực hiện hay không.

ProductsControllerTest

Bạn có thể chạy lệnh rake test:functionals để Rails chạy những test trên. Và Bạn sẽ nhận được 2 lỗi tại test "should create product""should update product". 

Đối với "should create product" thì lý do là vì bị trùng thuộc tính title, chúng ta đã quy định thuộc tính này phải là duy nhất, nhưng Rails lại lấy dữ liệu từ fixture để chèn vào CSDL nên mới bị trùng, và Rails lại không kiểm tra trường hợp bị trùng, chúng ta có thể sửa lại như sau:

require 'test_helper'

class ProductsControllerTest < ActionController::TestCase
    .
    .
    .
    test "should create product" do 
        assert_difference('Product.count', 0) do 
            post :create, product: { 
                    description: @product.description, 
                    image_url: @product.image_url, 
                    price: @product.price,
                    title: @product.title } 
        end
 
        assert_difference('Product.count') do
            post :create, product: {
                    :description => 'Some new book',
                    :image_url => 'some_img.jpg',
                    :price => 59.99,
                    :title => 'Some new title'
            } 
        end
        assert_redirected_to product_path(assigns(:product))
    end
    .
    .
    .
end

Đối với trường hợp bị trùng, chúng ta truyền thêm tham số thứ 2 vào phương thức assert_difference, đây là tham số hiệu, như đã nói ở trên mặc định phương thức này kiểm tra hiệu bằng 1, ở đây chúng ta kiểm tra hiệu bằng 0, tức là nếu không chèn thêm bản ghi mới vào thì số lượng các bản ghi không đổi, còn với trường hợp chèn thành công thì chúng ta kiểm tra một yêu cầu redirect có tồn tại không.

Tương tự, "should update product" cũng bị 2 lỗi vì Rails chưa kiểm tra trường hợp cập nhật thất bại, nếu bạn còn nhớ thì trong Fixtures có 2 đối tượng dữ liệu mẫu có title giống nhau, và cả 2 đều có thuộc tính image_url không có đuôi hợp lệ, chúng ta sửa lại như sau:

# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

one:
 title: MyString
 description: MyText
 price: 9.99
 image_url: MyString

two:
 title: MyString2
 description: MyText
 price: 9.99
 image_url: MyString.jpg

ruby:
 title: Ruby on Rails 4
 description: Zzzzzzz....
 price: 9.99
 image_url: img.jpg

Chúng ta sửa dữ liệu của two sao cho hợp lệ, còn one thì giữ nguyên. Tiếp theo chúng ta sửa đoạn code test như sau:

class ProductsControllerTest < ActionController::TestCase
    .
    .
    .
    test "should update product" do
        patch :update, id: @product, product: { 
            description: @product.description, 
            image_url: @product.image_url, 
            price: @product.price, 
            title: @product.title 
        }
        assert_response 200
 
        @product = products(:two)
        patch :update, id: @product, product: {
            :description => @product.description,
            :image_url => @product.image_url,
            :price => @product.price,
            :title => @product.title
        } 
        assert_redirected_to product_path(assigns(:product))
     end
    .
    .
    .
end

Đầu tiên chúng ta kiểm tra đối với fixture mặc định được tạo trong phần setup là  one, đây là dữ liệu không hợp lệ, Rails sẽ trả về response có thông báo lỗi, chú ý là response ở đây mang theo một thuộc tính thông báo lỗi, còn bản thân response lại là một gói tin HTTP được gửi thành công, nên chúng ta mới so sánh với mã trạng thái là 200. Sau đó chúng ta đổi lại biến @product lấy giá trị của fixture two, đây là fixture hợp lệ, khi cập nhật xong thì trang web của chúng ta sẽ được chuyển đến đường dẫn /products/<id>, do đó chúng ta kiểm tra với phương thức assert_redirected_to.

StoreControllerTest

Đối với controller store chỉ có một phương thức index thì có file test như sau:

require 'test_helper'

class StoreControllerTest < ActionController::TestCase
    test "should get index" do
        get :index
        assert_response :success
    end

end

Ở đây Rails chỉ kiểm tra xem khi trỏ đến hàm index này thì có nhận được một gói tin trả về với mã từ 200-299 (thành công – success) hay không.

Chúng ta kiểm tra thêm một số thứ bằng cách sửa lại như sau:

require 'test_helper'

class StoreControllerTest < ActionController::TestCase
    test "should get index" do
        get :index
        assert_response :success
        assert_select '#columns #side a', :minimum => 4
        assert_select '#main .entry', 3
        assert_select 'h3', 'Ruby on Rails 4'
        assert_select '.price', /\$[,\d]+\.\d\d/
    end
end

Phương thức assert_select sẽ kiểm tra xem trong nội dung HTML trả về có chứa những thành phần mà chúng ta quy định hay không.

Chẳng hạn như assert_select '#columns #side a', tức là kiểm tra xem trong nội dung có thẻ nào có id là columns hay không, và trong đó phải có một thẻ có id là side, rồi phải có thẻ <a>, ngoài ra thuộc tính minimum còn yêu cầu là phải có ít nhất 4 thẻ <a>.

Dòng assert_select '#main .entry', 3 yêu cầu nội dung trả về phải có một thẻ có id là main, trong đó có 3 thẻ có class là entry.

Dòng assert_select 'h3', 'Ruby on Rails 4' kiểm tra xem có thẻ <h3> nào có nội dung là Ruby on Rails 4 hay không.

Cuối cùng dòng assert_select '.price', /\$[,\d]+\.\d\d/ kiểm tra xem thẻ nào mà có class là price có nội dung khớp với đoạn biểu thức chính quy hay không, ở đây đoạn biểu thức chính quy này mô tả số tiền, có ký tự $, có 2 chữ số sau phần thập phân.

Bạn có thể chạy lệnh rake test:functionals để Rails kiểm tra tất cả các đoạn test trên. Có tổng cộng 8 đoạn test trong 2 lớp StoreControllerTestProductsControllerTest.

C:\Project\Rails\depot>rake test:functionals
Run options: --seed 46620

# Running:

........

Finished in 0.874736s, 9.1456 runs/s, 21.7208 assertions/s

8 runs, 19 assertions, 0 failures, 0 errors, 0 skips

Rails – Helper

Helper ở đây là các phương thức tiện ích mà chúng ta có thể gọi trong View (ERB).

Ví dụ

Chúng ta đã sử dụng các phương thức helper nhiều rồi, chẳng hạn như phương thức image_tag dùng để tạo thẻ <img>, phương thức sanitize dùng để loại bỏ kí tự lạ trong chuỗi…v.v.

Bây giờ chúng ta sửa file index.html.erb trong thư mục app/views/store như sau:

<% if notice %>
    <p id="notice"><%= notice %></p>
<% end %>
 
<h1>Product List</h1>
 
<% @products.each do |product| %>
    <div class="entry">
        <%= image_tag(product.image_url) %>
        <h3><%= product.title %></h3> 
        <%= sanitize(product.description) %>
        <div class="price_line">
            <span class="price"><%= number_to_currency(product.price) %></span>
        </div>
    </div>
<% end %>

Phương thức number_to_currency sẽ chuyển một số sang một chuỗi và chèn thêm kí tự định dạng tiền tệ vào trước. Mặc định là U.S. Dollars (ký tự $)

capture

Chúng ta có thể tùy chỉnh để hàm này hiển thị theo loại tiền tệ tùy thuộc vào người dùng truy cập từ đâu (chẳng hạn như hiển thị ‘đ’ nếu người dùng truy cập từ Việt Nam), chúng ta sẽ tìm hiểu chủ đề này sau.

Bạn có thể tìm hiểu các phương thức helper thường dùng tại đây:

http://guides.rubyonrails.org/action_view_overview.html

Rails – Tùy chỉnh layout

Như chúng ta đã biết, trong Rails có một file chứa code HTML có chức năng chính là định nghĩa cách các thành phần trong toàn bộ website được hiển thị như thế nào, chẳng hạn như trong blog Phở Code, cho dù bạn có đọc bài nào thì giao diện của website vẫn luôn có phần tiêu đề, thanh navigation (hay menu), vùng sidebar, footer…v.v file này được gọi là file Layout. Khi cần chỉnh sửa lại giao diện thì chúng ta chỉ cần chỉnh sửa file này là được.

Trong một project Rails thì file này là file application.html.erb nằm trong thư mục app/views/layout, chúng ta sẽ chỉnh sửa file này.

Chúng ta sửa lại nội dung file application.html.erb như sau:

<!DOCTYPE html>
<html>
<head>
<title>Books Store</title>
    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
    <%= csrf_meta_tags %>
</head>
<body id="store">
    <div id="banner">
        <%= image_tag("logo.png") %>
        <%= @page_title || "Books Store" %>
    </div>
    <div id="columns">
        <div id="side">
            <a href="#">Home</a><br />
            <a href="#">FAQ</a><br />
            <a href="#">News</a><br />
            <a href="#">Contact</a><br />
        </div>
        <div id="main">
            <%= yield %>
        </div>
    </div> 
</body>
</html>

Chúng ta định nghĩa một số id cho thẻ <div> để tùy chỉnh giao diện cho từng phần khác nhau.

Ở trong thẻ <div id="banner">, chúng ta gọi đến hàm image_tag() để tạo một thẻ <img> chứa thuộc tính src dẫn đến file tương ứng. File này bạn có thể tự tạo hoặc lấy ở đâu đó (lấy hàng miễn phí ấy 🙂 ) rồi bỏ vào thư mục app/assets/images.

Sau đó chúng ta gọi đến biến @page_title để hiển thị tiêu đề website, biến này sẽ được gửi từ một controller nào đó, ở đây chúng ta chưa khai báo, do đó chúng ta thực hiện phép toán OR (toán tử ||) với chuỗi “Books Store” để hiển thị chuỗi này nếu biến @page_title không tồn tại.

Tiếp theo là các vùng columns, side, main… đây chỉ là các thẻ <div> bình thường. Ở vùng main, chúng ta có dòng code gọi phương thức yield, phương thức này sẽ gọi controller gắn với đối tượng root mà chúng ta đã bàn đến trong bài trước, dùng để lấy nội dung hiển thị của controller đó, tức là phương thức yield này sẽ gọi đến phương thức index trong controller store, và nội dung trả về là danh sách các sản phẩm.

Cuối cùng chúng ta cần định nghĩa CSS cho các thẻ <div> trên bằng cách chèn thêm đoạn code sau vào trong file depot.css như sau:

...
#banner {
    background: #9c9;
    padding-top: 10px;
    padding-bottom: 10px;
    border-bottom: 2px solid;
    font: small-caps 40px/40px "Times New Roman", serif;
    color: #282;
    text-align: center;
}

#banner img {
    float: left;
    padding-left: 5px;
}

#columns {
    background: #141;
}

#main {
    margin-left: 17em;
    padding-top: 4ex;
    padding-left: 2em;
    background: white;
}

#side {
    float: left;
    padding-top: 1em;
    padding-left: 1em;
    padding-bottom: 1em;
    width: 16em;
    background: #141;
}

#side a {
    color: #bfb;
    font-size: small;
}

Kết quả chúng ta có trang web như sau:

capture

Rails – Routing

Routing là tính năng điều hướng một URL vào một phương thức nhất định, tính năng routing có trong hầu hết các web framework phổ biến như Django, Node.js…

Ví dụ

Chúng ta sẽ tạo một trang hiển thị sản phẩm và hiển thị trang này lên URL '/'.

Đầu tiên chúng ta tạo một controller có tên là store và có một phương thức là index như sau:

C:\Project\Rails\depot>rails generate controller store index
    create app/controllers/store_controller.rb
     route get 'store/index'
    invoke erb
    create   app/views/store
    create   app/views/store/index.html.erb
    invoke test_unit
    create   test/controllers/store_controller_test.rb
    invoke helper
    create   app/helpers/store_helper.rb
    invoke   test_unit
    invoke asserts
    invoke   coffee
    create     app/assets/javascripts/store.coffee
    invoke   scss
    create     app/assetss/stylesheets/store.scss

Nếu bạn còn nhớ thì mặc định Rails sẽ tự động điều hướng các URL dựa vào controller và các phương thức của chúng, chẳng hạn chúng ta vừa tạo controller tên store, trong đó có một phương thức tên index, thì Rails sẽ trỏ (hay route) URL /store/index về phương thức này, bạn có thể chạy server và test thử.

Thông tin về các routing này được lưu trong file routes.rb trong thư mục config như sau:

Rails.application.routes.draw do
    get 'store/index'   

    resources :products
    # The priority is based upon order of creation: first created -> highest priority.
    # See how all your routes lay out with "rake routes".

    # You can have the root of your site routed with "root"
    # root 'welcome#index'

    # Example of regular route:
    #     get 'products/:id' => 'catalog#view'

    # Example of named route that can be invoked with purchase_url(id: product.id)
    #     get 'products/:id/purchase' => 'catalog#purchase', as: :purchase

    # Example resource route (maps HTTP verbs to controller actions automatically):
    #     resources :products

    # Example resource route with options:
    #     resources :products do
    #         member do
    #             get 'short'
    #             post 'toggle'
    #         end
    #
    #         collection do
    #             get 'sold'
    #         end
    #     end

    # Example resource route with sub-resources:
    #     resources :products do
    #         resources :comments, :sales
    #         resource :seller
    #     end

    # Example resource route with more complex sub-resources:
    #     resources :products do
    #         resources :comments
    #         resources :sales do
    #             get 'recent', on: :collection
    #         end
    #     end

    # Example resource route with concerns:
    #     concern :toggleable do
    #         post 'toggle'
    #     end
    #     resources :posts, concerns: :toggleable
    #     resources :photos, concerns: :toggleable

    # Example resource route within a namespace:
    #     namespace :admin do
    #         # Directs /admin/products/* to Admin::ProductsController
    #         # (app/controllers/admin/products_controller.rb)
    #         resources :products
    #     end
end

File này do Rails tự tạo, dòng get 'store/index' rất trực quan, Rails định nghĩa đoạn URL /store/index sẽ được chuyển đến phương thức index trong controller store.

Tuy nhiên có một điều khó hiểu là trong các bài trước chúng ta có thể trỏ đến /product, /product/1, /product/1/edit ..v.v nhưng trong file routes.rb này lại không có dòng nào dạng như get 'product', lý do chúng ta có thể trỏ đến các URL product kia là nhờ vào dòng:

resources :products

Thông thường khi tạo một model chúng ta hay định nghĩa các phương thức thêm, sửa, xóa, cập nhật..v.v đây được gọi chung là các thao tác CRUD (CreateReadUpdateDelete), và như lẽ thông thường thì chúng ta sẽ phải tự định nghĩa controller cũng như các phương thức để thực hiện các thao tác đó, rồi khai báo các dòng get trong file routes.rb.

Chính vì thao tác CRUD quá phổ biến nên Rails đã đơn giản hóa việc này cho chúng ta, khi chúng ta khai báo products với resources, Rails sẽ hiểu là phải tự động xử lý các URL /product/... cho chúng ta luôn mà không cần phải khai báo ở đâu cả. Ngoài ra các lời gọi URL còn mang theo cả thông tin về phương thức nữa, như GET, POST, PUT…v.v Mặc định thì Rails định nghĩa 6 phương thức cùng với URL cho một model tương ứng như sau:

Phương thức gởi URL Controller#Phương thức xử lý Ý nghĩa
GET /products/new /products#new Trả về form HTML tạo một đối tượng mới
POST /products /products#create Tạo một đối tượng mới
GET /products /products#show Hiển thị thông tin về một đối tượng cụ thể
GET /products/edit /products#edit Trả về form chỉnh sửa một đối tượng
PATCH/PUT /products /products#update Cập nhật một đối tượng
DELETE /products /products#destroy Hủy một đối tượng

Tiếp theo, nếu bạn còn nhớ thì URL '/' sẽ được trả về một trang mẫu như sau:

capture

Chúng ta có thể sửa lại để Rails trỏ đến trang do chúng ta quy định như sau:

Rails.application.routes.draw do
    get 'store/index'

    resources :products
 
    root :to => 'store#index'
end

Bằng cách định nghĩa thuộc tính to của đối tượng root tới một URL như trên (controller và phương thức ngăn cách nhau bởi dấu '#') .

Tiếp theo chúng ta tạo một đối tượng để lưu trữ toàn bộ bản ghi trong controller, chúng ta sửa file store_controller.rb như sau:

class StoreController < ApplicationController
    def index
        @products = Product.all
    end
end

Chúng ta tham chiếu tới model Product và gọi phương thức all là sẽ lấy được danh sách các đối tượng (hay các bản ghi trong CSDL) của model đó.

Bây giờ chúng ta sẽ sửa lại view cho trang này một tí để hiển thị danh sách các sản phẩm, chúng ta sửa file view tương ứng là file app/views/store/index.html.erb như sau:

<% if notice %>
   <p id="notice"><%= notice %></p>
<% end %>

<h1>Product List</h1>

<% @products.each do |product| %>
    <div class="entry">
        <%= image_tag(product.image_url) %>
        <h3><%= product.title %></h3> 
        <%= sanitize(product.description) %>
        <div class="price_line">
            <span class="price"><%= product.price %></span>
        </div>
    </div>
<% end %>

Biến notice lưu giữ một chuỗi text thông báo, chẳng hạn như thông báo tạo thành công, hủy thành công… nếu có tồn tại thì chúng ta hiển thị ra.

Hàm image_tag sẽ tạo một thẻ <img> và nhận tham số là đường dẫn tham chiếu đến file ảnh.

Hàm sanitize() có tác dụng loại bỏ một số thẻ ký tự lạ trong một chuỗi. Chúng ta sẽ tìm hiểu sau.

capture

Rails – Unit Testing

Trong bài này chúng ta sẽ thực hiện kiểm tra code bằng phương pháp Unit Testing.

Nếu bạn chưa biết thì có thể nói đơn giản Unit Testing là phương pháp kiểm tra xem các đoạn code chúng ta viết ra có chạy chính xác hay không. Rails có riêng một nền tảng hỗ trợ Unit Testing rất mạnh, chúng ta sẽ tìm hiểu cách sử dụng nền tảng này.

Khi chúng ta tạo một project thì ngoài thư mục app, Rails còn tạo ra một thư mục có tên là test, trong thư mục này cũng có thư mục models, controllers và một vài thư mục cũng như file khác tương tự như trong thư mục app. Ý nghĩa của thư mục này là tạo ra một môi trường giả lập giống hệt như môi trường “chính” dùng để kiểm tra tất cả mọi thứ, môi trường này có models riêng, controller riêng và cả CSDL riêng nữa.

Khi chúng ta tạo một model bằng lệnh generate scaffold, thì trong thư mục gốc của project có một thư mục tên là test/models, trong đó sẽ có một file có tên theo dạng <tên model>_test.rb, trong ứng dụng depot mà chúng ta đã làm thì file này sẽ là product_test.rb, file này có nội dung như sau:

require 'test_helper'

class ProductTest < ActiveSupport::TestCase
    # test "the truth" do
    # assert true
    # end
end

Ở đây Rails định nghĩa lớp ProductTest kế thừa từ lớp ActiveSupport::TestCase, lớp này thực ra cũng chỉ là kế thừa từ lớp Test::Unit::TestCase có trong Ruby thôi. Trong lớp này có một khối lệnh (đã được mặc định là comment) có cú pháp test <tên test> do...end, bạn cứ hình dung đây cũng giống như là một định nghĩa hàm/phương thức thôi cho đơn giản, chúng ta sẽ gọi chúng là “test”, khi kiểm tra thì Rails sẽ gọi những “test” này để chạy.

Tiếp theo trong khối lệnh đó có dòng lệnh assert true, assert là một phương thức, phương thức assert được dùng để:

  • Kiểm tra thuộc tính này có giá trị như thế kia không?
  • Kiểm tra đối tượng có phải là nil hay không.
  • Có phải đoạn code kia sẽ giải phóng exception hay không.

Nói tóm lại giá trị trả về của phương thức assert là true hoặc false, và cứ mỗi lần có một phương thức assert nào đó trả về false thì Rails báo lỗi.

Trước khi đi vào phần kiểm tra thì chúng ta phải chạy lệnh rake db:migrate RAILS_ENV=test:

C:\Project\Rails\depot>rake db:migrate RAILS_ENV=test
== 20161106144824 CreateProducts: migrating ===========================================
-- create_table(:products)
   -> 0.0024s
== 20161106144824 CreateProducts: migrated (0.0066s)===================================

Lệnh này cũng giống như lệnh rake db:migrate thôi, tức là sẽ tạo các bảng trong CSDL, chỉ khác là tạo cho môi trường test, không đụng chạm gì tới file CSDL của app cả, mặc định Rails sử dụng CSDL SQLite3 nên file được tạo ra là file test.sqlite3 nằm trong thư mục db.

Ví dụ Unit Testing

Chúng ta sửa file product_test.rb như sau:

require 'test_helper'

class ProductTest < ActiveSupport::TestCase
    test "Thuoc tinh khong duoc de trong" do 
        product = Product.new
        assert product.invalid?
        assert product.errors[:title].any?
        assert product.errors[:description].any?
        assert product.errors[:price].any?
        assert product.errors[:image_url].any?
    end
end

Trong đoạn code trên, chúng ta định nghĩa một test có tên là “Thuoc tinh khong duoc de trong”, trong đó chúng ta tạo một đối tượng Product và không có thuộc tính nào có giá trị nào cả. Tiếp theo chúng ta gọi phương thức assert kiểm tra giá trị của phương thức invalid?, phương thức invalid? là một phương thức của lớp ActiveRecord::Base, phương thức này sẽ kiểm tra xem các thuộc tính của lớp tương ứng có hợp lệ hay không dựa trên các phương thức validates (mà chúng ta đã làm trong bài trước), tất nhiên là không hợp lệ vì không có thuộc tính nào có giá trị nào cả, và Rails sẽ làm một việc là gán một chuỗi thông báo lỗi ứng với từng thuộc tính vào thuộc tính errors (đây là một mảng và cũng là một thuộc tính của lớp ActiveRecord::Base).

Tiếp theo chúng ta lại kiểm tra xem các phần tử của mỗi thuộc tính tương ứng trong thuộc tính errors có tồn tại hay không thông qua phương thức any?, phương thức này kiểm tra xem một thuộc tính nào đó có tồn tại hay không, ở đây là có tồn tại và phương thức assert sẽ trả về true.

Bây giờ chúng ta có thể chạy test bằng lệnh rake test:units và chúng ta sẽ có đoạn output tương tự như thế này:

C:\Project\Rails\depot>rake test:units
Run options: --seed 26133

# Running:

Finished in 0.123351s, 8.1069 runs/s, 40.5347 assertions/s.

1 runs, 5 assertions, 0 failures, 0 errors, 0 skips

Đoạn output trên thông báo là việc test đã thành công, các đoạn code kiểm tra không bị lỗi (0 failures), tức là chúng ta đã viết các phương thức validates đúng.

Bây giờ chúng ta tạo một test khác như sau:

require 'test_helper'

class ProductTest < ActiveSupport::TestCase
    test "Thuoc tinh khong duoc de trong" do 
        ...
    end 
 
    test "Price phai lon hon 1.0" do
        product = Product.new(:title => 'Hmm...',
                              :description => 'Err...',
                              :image_url => 'Something.jpg')
 
        product.price = -1
        assert product.invalid? 
        assert_equal "must be greater than or equal to 1.0",
                     product.errors[:price].join('') 
 
        product.price = 0
        assert product.invalid?
        assert_equal "must be greater than or equal to 1.0",
                     product.errors[:price].join('')
 
        product.price = 5
        assert product.valid?
 
    end
end

Chúng ta tạo một test có tên "Price phai lon hon 1.0" để kiểm tra xem phương thức validates với thuộc tính price có hoạt động chính xác hay không.

Ở đây chúng ta tạo một đối tượng Product với các thuộc tính còn lại có đầy đủ giá trị hợp lệ, còn thuộc tính price sẽ lần lượt có 3 giá trị là -1, 0, và 5 để kiểm tra.

Đối với giá trị -1 và 0, nếu hàm validates chúng ta viết chạy đúng thì sau khi gọi hàm invalid?, trong thuộc tính errors sẽ có một phần tử có khóa là :price và có giá trị là "must be greater than or equal to 1.0" (phải lớn hơn hoặc bằng 1.0). Chúng ta gọi phương thức assert_equal, phương thức này nhận vào 2 chuỗi và so sánh xem 2 chuỗi đó có bằng nhau hay không, ở đây chúng ta kiểm tra xem chuỗi trong mảng errors có giống với chuỗi "must be..." ở trên hay không. Ở đây thực chất giá trị trong mảng errors được lưu dưới dạng một mảng khác nữa nên chúng ta gọi phương thức join() để chuyển giá trị đó thành một chuỗi thì mới so sánh được.

Đối với giá trị 5 thì hiển nhiên là giá trị này hợp lệ (nếu chúng ta viết validates đúng) nên chúng ta kiểm tra bằng phương thức valid?.

Bạn có thể chạy rake test:units để kiểm tra, nếu có lỗi thì tức là các đoạn validates viết sai.

Tiếp theo chúng ta viết đoạn test kiểm tra tên file trong thuốc tính image_url có hợp lệ hay không như sau:

require 'test_helper'

class ProductTest < ActiveSupport::TestCase
    test "Thuoc tinh khong duoc de trong" do 
        ...
    end 
 
    test "Price phai lon hon 1.0" do
        ...
    end
 
    def new_product(image_url)
        Product.new(:title => 'Hmm...',
                    :description => 'Err...',
                    :image_url => image_url,
                    :price => 5)
    end
 
    test "image url" do
        url_hop_le = %w{ img.gif img.jpg img.png IMG.JPG IMG.Jpg http://a.b.c/x/y/z/img.gif }
        url_khong_hop_le = %w{ img.doc img.gif/more img.gif.more }
 
        url_hop_le.each do |name|
            assert new_product(name).valid?, "#{name} phai hop le"
        end
 
        url_khong_hop_le.each do |name|
            assert new_product(name).invalid?, "#{name} khong hop le"
        end
    end
end

Đầu tiên chúng ta viết phương thức new_product() để tạo một đối tượng Product mới có thuộc tính image_url được truyền vào từ tham số. Tiếp theo là đoạn test có tên “image url”, trong này chúng ta tạo 2 mảng url_hop_leurl_khong_hop_le chứa các tên file hợp lệ và không hợp lệ. Sau đó chúng ta duyệt qua từng mảng, cứ mỗi lần duyệt thì chúng ta tạo một đối tượng Product mới từ hàm new_product và gọi phương thức assert để kiểm tra. Ở đây phương thức assert được truyền thêm một tham số thứ 2 nữa, đây chẳng qua chỉ là một chuỗi và chuỗi này sẽ được nối vào thuộc tính lỗi trong mảng errors để có gì thì tìm cho dễ thôi.

Fixtures

Trong số các điều kiện kiểm tra mà chúng ta đã viết thì còn một điều kiện nữa là kiểm tra xem thuộc tính title có phải là duy nhất hay không. Để kiểm tra thì có một cách là mỗi lần test chúng ta tạo một đối tượng cụ thể, sau đó lưu vào CSDL, rồi lại một đối tượng khác có thuộc tính giống như đối tượng vừa tạo và kiểm tra, tuy nhiên cách này có hơi rắc rối, do đó Rails cung cấp cho chúng ta cơ chế Fixtures.

Để hiểu một cách đơn giản thì fixtures là các dữ liệu mẫu đã được lưu trong CSDL test, mỗi khi chạy test thì chúng ta có thể sử dụng các dữ liệu này.

Trong Rails thì các dữ liệu fixture được lưu trong thư mục mặc định là test/fixtures dưới định dạng CSV hoặc YAML. Khi tạo model thì Rails cũng tạo một file fixture mẫu trong thư mục này với định dạng YAML, ở đây chúng ta tạo model Product và file fixture tương ứng là products.yml, và chúng ta cũng sẽ dùng định dạng này luôn cho tiện. File products.yml mặc định có nội dung như sau:

# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

one:
    title: MyString
    description: MyText
    image_url: MyString
    price: 9.99

two:
    title: MyString
    description: MyText
    image_url: MyString
    price: 9.99

Lưu ý là các file .yml này luôn phải trùng với tên một bảng nào đó, không phải ngẫu nhiên mà file này lại có tên là products.yml. Ngoài ra cứ mỗi lần chúng ta chạy lệnh rake test:units thì các bảng trong file test.sqlite3 luôn được tạo mới và được chèn sẵn các dữ liệu fixtures này.

Cú pháp YAML có lẽ cũng không khó hiểu lắm nên mình sẽ không giải thích đoạn code trên 🙂 Chỉ có một lưu ý là các từ như one, two, đây là các tên các dòng để chúng ta có thể tham chiếu khi viết test, chứ các tên này không được lưu vào CSDL.

Bây giờ chúng ta sẽ thêm một bản ghi khác vào fixture như sau:

# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

one:
 title: MyString
 description: MyText
 image_url: MyString
 price: 9.99

two:
 title: MyString
 description: MyText
 image_url: MyString
 price: 9.99

ruby:
 title: Ruby on Rails 4
 description: Zzzzzzz....
 price: 9.99
 image_url: img.jpg

Lưu ý là các dòng được thụt vào bằng một dấu cách, không phải là dấu tab.

Tiếp theo chúng ta viết test như sau:

require 'test_helper'

class ProductTest < ActiveSupport::TestCase
    test "Thuoc tinh khong duoc de trong" do 
        ...
    end 
 
    test "Price phai lon hon 1.0" do
        ...
    end
 
    def new_product(image_url)
        ...
    end
 
    test "image url" do
        ...
    end
 
    fixtures :products
 
    test "title khong duoc trung" do 
        product = Product.new(:title => products(:ruby).title,
            :description => "Hmm...",
            :image_url => "img.jpg",
            :price => 5)
        assert !product.save
        assert_equal "has already been taken",
                     product.errors[:title].join('')
     end
end

Dòng fixtures :products sẽ chỉ định Rails lấy những bản ghi được định nghĩa trong file products.yml từ bảng products trong file test.sqlite3. Sau đó Rails sẽ tạo một phương thức có tên trùng với tên bảng, phương thức này sẽ nhận vào tên fixtures để tìm các bản ghi có tên tương ứng và tạo thành một đối tượng thuộc model đó, nhờ đó chúng ta có thể đọc dữ liệu từ bản ghi này.

Trong ví dụ trên, chúng ta tạo test có tên “title khong duoc trung”, trong đó chúng ta tạo một đối tượng Product có thuộc tính title được lấy từ đối tượng tạo ra từ phương thức products(:ruby). Khi chúng ta gọi phương thức save để lưu đối tượng mới vào CSDL thì phương thức này sẽ trả về false, tức là không hợp lệ và assert sẽ tạo thông báo lỗi vào thuộc tính errors.

Bạn có thể chạy lệnh rake test:units để kiểm tra.