透過不同的foreign_key可以讓has_one關聯多筆資料 (名稱暫定)

問題

EC網站在結帳時會需要記錄購物人地址(billing address)跟寄送地址(shipping address),所以在記錄訂單資訊的時候會看到這樣的table,billing跟shipping的資訊都放在同個table裡。

class CreateOrderInfos < ActiveRecord::Migration
  def change
    create_table :order_infos do |t|
      t.string :billing_name
      t.string :billing_address
      t.string :shipping_name
      t.string :shipping_address     
      t.timestamps null: false
    end
  end
end

但是當欄位資訊變多時就會變成

class CreateOrderInfos < ActiveRecord::Migration
  def change
    create_table :order_infos do |t|
      t.string :billing_first_name
      t.string :billing_last_name
      t.string :billing_address
      t.string :billing_email
      t.string :billing_company
      t.string :billing_city
      t.string :billing_postal_code
      t.string :billing_country
      t.string :billing_phone
      t.string :shipping_first_name
      t.string :shipping_last_name
      t.string :shipping_address
      t.string :shipping_email
      t.string :shipping_company
      t.string :shipping_city
      t.string :shipping_postal_code
      t.string :shipping_country
      t.string :shipping_phone
      t.timestamps null: false
    end
  end
end

你可以看到table的欄位變得很冗長,而且這些欄位除了_(底線)前面的名稱以外其實billing跟shipping所需要的欄位都是一樣的,如果又拆成兩個table然後裡面的欄位都一樣呢?但這樣又不符合DRY,所以是不是能用同一個table去儲存不同情況下的資料呢?這就是今天的討論。

實作

修改前

我們先來看修改前的例子,從下面的程式碼可以看到table裡很多重複資訊的欄位外,在view裡的form也是一樣的情況,假設如果今天需要多加一筆新的資訊叫fox,照現在的架構就必須在table新增billing_foxshipnning_fox,當然在view的form裡也要分別加上<%= c.input :billing_fox%><%= c.input :shipping_fox%>,這樣的架構下不覺得很煩嗎?當需要一筆新的資訊的時候還要為了區別是billing還是shipping而加了兩次

OrderInfosTable.rb
class CreateOrderInfos < ActiveRecord::Migration
  def change
    create_table :order_infos do |t|
        t.integer :order_id
        t.string :billing_first_name
        t.string :billing_last_name
        t.string :billing_address
        t.string :billing_email
        t.string :billing_company
        t.string :billing_city
        t.string :billing_postal_code
        t.string :billing_country
        t.string :billing_phone
        t.string :shipping_first_name
        t.string :shipping_last_name
        t.string :shipping_address
        t.string :shipping_email
        t.string :shipping_company
        t.string :shipping_city
        t.string :shipping_postal_code
        t.string :shipping_country
        t.string :shipping_phone
        t.timestamps null: false
    end
  end
end
models
class Order < ActiveRecord::Base
  has_one :info ,class_name: "OrderInfo"
  accepts_nested_attributes_for :info
end

class OrderInfo < ActiveRecord::Base
  belongs_to :order
end
checkout.html.erb
  <%= simple_form_for @order do |f| %>
    <%= f.simple_fields_for :info do |c| %>
        <%= c.input :billing_first_name%>
        <%= c.input :billing_last_name%>
        <%= c.input :billing_address%>
        <%= c.input :billing_email%>
        <%= c.input :billing_company%>
        <%= c.input :billing_city%>
        <%= c.input :billing_postal_code%>
        <%= c.input :billing_country%>
        <%= c.input :billing_phone%>
        <%= c.input :shipping_first_name%>
        <%= c.input :shipping_last_name%>
        <%= c.input :shipping_address%>
        <%= c.input :shipping_email%>
        <%= c.input :shipping_company%>
        <%= c.input :shipping_city%>
        <%= c.input :shipping_postal_code%>
        <%= c.input :shipping_country%>
        <%= c.input :shipping_phone%>
    <% end %>
  <% end %>
carts_controller.rb
class CartsController < ApplicationController
  
  def checkout
    @order = current_user.orders.build
    @info = @order.build_info
  end
end
order_controller.rb
def crete
  @order  = Order.create(order_params)
  @order.save
end
  
    private
    def order_params
        params.require(:order).permit( info_attributes: [
            :billing_first_name,
            :billing_last_name,
            :billing_email,
            :billing_address,
            :billing_phone,
            :billing_company,
            :billing_postal_code,
            :billing_country,
            :billing_city,
            :shipping_first_name,
            :shipping_last_name,
            :shipping_email,
            :shipping_address,
            :shipping_phone,
            :shipping_company,
            :shipping_postal_code,
            :shipping_country,
            :shipping_city])
    end

修改後

看一下修改後的code是不是覺得簡單乾淨多了,而且能重複利用到的地方都再次利用了。

OrderInfosTable.rb
class CreateOrderInfos < ActiveRecord::Migration
  def change
    create_table :order_infos do |t|
        t.integer :billing_order_id
        t.integer :shipping_order_id
        t.string :first_name
        t.string :last_name
        t.string :address
        t.string :email
        t.string :company
        t.string :city
        t.string :postal_code
        t.string :country
        t.string :phone
    end
  end
end
models
class Order < ActiveRecord::Base
  has_one :billing,  class_name:"OrderInfo", foreign_key: "billing_order_id"
  has_one :shipping, class_name:"OrderInfo", foreign_key: "shipping_order_id"
  accepts_nested_attributes_for :billing
  accepts_nested_attributes_for :shipping
end
class OrderInfo < ActiveRecord::Base
  belongs_to :billing_order, class_name:"Order"
  belongs_to :shipping_order, class_name:"Order"
end
checkout.html.erb
  <%= simple_form_for @order do |f| %>
      <%= render partial "order_info_form" , locals:{info_type: :billing , f:f} %>
      <%= render partial "order_info_form" , locals:{info_type: :shipping , f:f} %>
  <% end %>
_order_info_form.html.erb
<%= f.simple_fields_for info_type , defaults:{required:false} do |c| %>
    <%= c.input :first_name %>
    <%= c.input :last_name %>
    <%= c.input :email %>
    <%= c.input :company %>
    <%= c.input :address %>
    <%= c.input :city %>
    <%= c.input :postal_code %>
    <%= c.input :country ,as: :country %>
    <%= c.input :phone %>
    <%= c.input :additional_info %>
<% end %>
carts_controller.rb
class CartsController < ApplicationController
  
  def checkout
    @order = current_user.orders.build
    @billing = @order.build_billing
    @shipping = @order.build_shipping
  end
end
order_controller.rb
def crete
  @order  = Order.create(order_params)
  @order.save
  end
private
def order_params
column_arr = [:first_name,
              :last_name,:email,
              :address,:phone,
              :company,
              :postal_code,
              :country,
              :city]

params.require(:order).permit( billing_attributes: column_arr,shipping_attributes: column_arr)
end

解說

從最重要的這兩個model開始說起,我們先來看order model,在order model裡用has_one去關聯另一個orderInfo model。看到這裡你可能會有這樣的疑問,用has_one關聯的話在一個table裡不是只會有一筆資料嗎?這邊怎麼會有兩個has_one呢?這樣不就是兩筆資料了嗎怎麼不用has_many呢??

在一般的情況下使用has_one做一對一關聯,子關聯的model(有belongs_to的)一定會有母關聯model(有has_one的)的id,這邊稱為foreign_key,所以order has_one orderInfo 在order Info的table裡就會有order_id這樣的欄位,規則是MODEL_NAME_ID,所以當再取資料時用order.order_info就會拿order的id去order_info裡自動找到對應到的欄位order_id然後找到該筆資料,當資料有重複新增時,has_one顧名思義就是只有一筆所以新的會蓋掉舊的資料。

但這邊就是用has_oneforeign_key的技巧,可以讓你重複利用一樣的欄位並且依據情況去儲存兩筆不同情況的資料而且是用has_one但不會覆蓋彼此資料,讓你不用因為要儲存不同的資料而多開一個一模一樣的table。

首先先在CreateOrderInfos的migration裡新增billing_order_idshipping_order_id欄位,利用不同的foreign_key在同一個table裡去區分不一樣的資料,所以即使是用has_one也不會覆蓋掉對方的資料,因為是用不同的foreign_key,所以在orderInfo的table裡雖然是用has_one但有兩筆資料關聯到同一個order model。

models
class Order < ActiveRecord::Base
  has_one :billing,  class_name:"OrderInfo", foreign_key: "billing_order_id"
  #一筆資料我稱之為billing(可以自己取名),並且把要關聯的對象指向OrderInfo model ,然後要用billing_order_id這個欄位當作foreign_key的欄位

  has_one :shipping, class_name:"OrderInfo", foreign_key: "shipping_order_id"
  #同上,並叫做shipping

  accepts_nested_attributes_for :billing
  accepts_nested_attributes_for :shipping
  #用nested form去做資料的新增,這樣只要儲存order model另外兩個關聯到的billing跟shipping就會自動儲存

end
class OrderInfo < ActiveRecord::Base
  belongs_to :billing_order, class_name:"Order"
  belongs_to :shipping_order, class_name:"Order"
  #有兩個has_one所以也會有兩個belongs_to,然後這邊belongs_to要用OrderInfo table裡foreign_key _id前的名稱,這樣關聯回去時才知道要用哪個foreign_key得值去關聯

end

rails c 取值的結果

@order = Order.crete
#=>#<Order:0x007fd906522a10> {

    :id => 1,
    :created_at => Fri, 03 Jun 2016 17:08:00 UTC +00:00,
    :updated_at => Fri, 03 Jun 2016 17:08:00 UTC +00:00,
}

billing_info = @order.build_billing.save
@order.billing
#=>#<OrderInfo:0x007fd9065d9828> {

    :id => 1,
    :billing_order_id => 1,
    :shipping_order_id => nil,
    :first_name => nil,
    :last_name => nil,
    :address => nil,
    :email => nil,
    :company => nil,
    :city => nil,
    :postal_code => nil,
    :country => nil,
    :phone => nil,
    :additional_info => nil,
    :created_at => Mon, 06 Jun 2016 17:13:48 UTC +00:00,
    :updated_at => Mon, 06 Jun 2016 17:13:48 UTC +00:00
}

shipping_info = @order.build_shipping.save
@order.shipping
#=>#<OrderInfo:0x007fd9065d9828> {

    :id => 1,
    :billing_order_id => nil,
    :shipping_order_id => 1,
    :first_name => nil,
    :last_name => nil,
    :address => nil,
    :email => nil,
    :company => nil,
    :city => nil,
    :postal_code => nil,
    :country => nil,
    :phone => nil,
    :additional_info => nil,
    :created_at => Mon, 06 Jun 2016 17:13:48 UTC +00:00,
    :updated_at => Mon, 06 Jun 2016 17:13:48 UTC +00:00
}

@billing_info.billing_order
#=>#<Order:0x007fd906522a10> {

    :id => 1,
    :created_at => Fri, 03 Jun 2016 17:08:00 UTC +00:00,
    :updated_at => Fri, 03 Jun 2016 17:08:00 UTC +00:00,
}

@shipping_info.shipping_order
#=>#<Order:0x007fd906522a10> {

    :id => 1,
    :created_at => Fri, 03 Jun 2016 17:08:00 UTC +00:00,
    :updated_at => Fri, 03 Jun 2016 17:08:00 UTC +00:00,
}

OrderInfo.all
#=>OrderInfo Load (0.3ms)  SELECT "order_infos".* FROM "order_infos"

[
    [0] #<OrderInfo:0x007fd909312650> {

                       :id => 1,
         :billing_order_id => 1,
        :shipping_order_id => nil,
               :first_name => nil,
                :last_name => nil,
                  :address => nil,
                    :email => nil,
                  :company => nil,
                     :city => nil,
              :postal_code => nil,
                  :country => nil,
                    :phone => nil,
          :additional_info => nil,
               :created_at => Mon, 06 Jun 2016 17:13:48 UTC +00:00,
               :updated_at => Mon, 06 Jun 2016 17:13:48 UTC +00:00
    },
    [1] #<OrderInfo:0x007fd909312510> {

                       :id => 1,
         :billing_order_id => nil,
        :shipping_order_id => 1,
               :first_name => nil,
                :last_name => nil,
                  :address => nil,
                    :email => nil,
                  :company => nil,
                     :city => nil,
              :postal_code => nil,
                  :country => nil,
                    :phone => nil,
          :additional_info => nil,
               :created_at => Mon, 06 Jun 2016 17:29:36 UTC +00:00,
               :updated_at => Mon, 06 Jun 2016 17:29:36 UTC +00:00
    }
]

這樣就可以做到用has_one在同一個table裡根據foreign_key的不同兒可以關聯到多筆不同的資料。

view整理

因為是用一樣的欄位名稱所以nested form的部分也可以拆成partial重複利用,只要帶參數進去就可以產生不同的nested form。

checkout.html.erb
  <%= simple_form_for @order do |f| %>
      <%= render partial "order_info_form" , locals:{info_type: :billing , f:f} %>
      <%= render partial "order_info_form" , locals:{info_type: :shipping , f:f} %>
  <% end %>
_order_info_form.html.erb
<%= f.simple_fields_for info_type , defaults:{required:false} do |c| %>
    <%= c.input :first_name %>
    <%= c.input :last_name %>
    <%= c.input :email %>
    <%= c.input :company %>
    <%= c.input :address %>
    <%= c.input :city %>
    <%= c.input :postal_code %>
    <%= c.input :country ,as: :country %>
    <%= c.input :phone %>
    <%= c.input :additional_info %>
<% end %>

在controller的部分strong parameters也變得很乾淨

order_controller.rb
    private
    def order_params
    column_arr = [:first_name,
                  :last_name,:email,
                  :address,:phone,
                  :company,
                  :postal_code,
                  :country,
                  :city]
    
    params.require(:order).permit( billing_attributes: column_arr,shipping_attributes: column_arr)
    end

結論

看起來是一對一的資料關聯但實際上是一對二,是不是覺得有點神奇,這樣做的好處主要的目的還是在如果有一樣的東西就盡量讓他可以重複使用,這樣不管在新增資料或是之後要修改資料時都會比較方便,看起來也比較乾淨清楚。

補充

在填寫訂單資訊時常常有那種寄送地址同訂單地址的選項,這時候如果billing的資料跟shipping的資料都是相同的話其實也不用特地再建一筆一模一樣shipping的資料,只要把billing裡的shipping_order_id update跟billing_order_id一樣的值就好了,然後shipping的資料就可以砍掉了。這樣用order.biling或是order.shipping都會找到同一筆資料,節省空間

#if shipping info same with billing info ,checkbox is true

@order  = Order.create(order_params)
@order.save
@billing = @order.billing
@billing .update(shipping_order_id:@order.id)
@order.shipping.destroy
@order.billing 
@order.shipping 
#will find the same data

#=>#<OrderInfo:0x007fd9065d9828> {

    :id => 1,
    :billing_order_id => 1,
    :shipping_order_id => 1,
    :first_name => nil,
    :last_name => nil,
    :address => nil,
    :email => nil,
    :company => nil,
    :city => nil,
    :postal_code => nil,
    :country => nil,
    :phone => nil,
    :additional_info => nil,
    :created_at => Mon, 06 Jun 2016 17:13:48 UTC +00:00,
    :updated_at => Mon, 06 Jun 2016 17:13:48 UTC +00:00
}

或是不要用accepts_nested_attributes_for 自動建立子關聯的資料,自己把billing建出來,然後也是在shipping_order_id塞一樣的order id,這樣就不用像上面一樣建了shipping資料後又把它刪掉

orderController
#without accepts_nested_attributes_for

def create
@order = Order.create
@billing = @order.build_billing(order_params[:billing])
@billing .update(shipping_order_id:@order.id)
#如果不用accepts_nested_attributes_for記得strong parameters也要改

  private
  def order_params
    column_arr = [:first_name,:last_name,:email,:address,:phone,:company,:postal_code,:country,:additional_info,:city]
    params.require(:order).permit( billing: column_arr,shipping: column_arr)
    #- params.require(:order).permit( billing_attributes: column_arr,shipping_attributes: column_arr)

  end
end

感謝

最後感謝sdlong大大的指點迷津~
creating-multiple-associations-with-the-same-table

comments powered by Disqus