Micropost refinements

這節我們要來改善一下 user 和 micropost 之間的關聯:

  • 按照特定的順序取得 user 的 microposts
  • 讓 microposts 依存(dependent) users,一旦 user 銷毀,就自動刪除 user 的所有 microposts

Default scope

預設中,user.microposts 方法無法確保 microposts 的順序,但按照一般 blogs 以及 Twitter 的習慣,我們希望 microposts 按照發布的時間倒序排列,也就是最新發佈的 micropost 會在最前面。(之前要列出所有使用者列表時也有相同問題)

因此,我們要使用 default scope

這樣的功能很容易導致測試意外通過(例如就算 application code 不對,測試也會通過),所以我們要使用 TDD(test-driven development)來確保實現的方式是正確的。首先,要先編寫一個測試,檢查資料庫中的第一篇 micropost 和 fixture 裡名為 most_recent 的 micropost 一樣:

test/models/micropost_test.rb

require 'test_helper'

class MicropostTest < ActiveSupport::TestCase
  .
  .
  .
  test "order should be most recent first" do
    assert_equal microposts(:most_recent), Micropost.first
  end
end

上述程式碼需要一些 mcroposts fixtures,所以我們要來增加一下:

test/fixtures/microposts.yml

orange:
  content: "I just ate an orange!"
  created_at: <%= 10.minutes.ago %>

tau_manifesto:
  content: "Check out the @tauday site by @mhartl: http://tauday.com"
  created_at: <%= 3.years.ago %>

cat_video:
  content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
  created_at: <%= 2.hours.ago %>

most_recent:
  content: "Writing a short test"
  created_at: <%= Time.zone.now %>

注意,我們使用 ERb 設定 created_at 欄位的值,因為這個欄位是由 Rails 自動更新,通常不可能手動設定,但在 fixtures 可以做到。實際上可能不用自己設定這些屬性,因為在某些系統中 fixtures 會按照定義的順序建立。

在我們的例子中,最後一個 fixture 是最後被建立的(所以會是最新的 micropost),但不要依賴這種行為,非常不可靠,而且不同系統間會有差異。

現在執行測試會是失敗的(Red):

$ bundle exec rake test TEST=test/models/micropost_test.rb \
>                       TESTOPTS="--name test_order_should_be_most_recent_first"

我們要使用 rails 提供的 default_scope 方法讓測試通過,用它來設定從資料庫中讀取資料的預設順序。為了得到特定的順序,我們要在 default_scope 方法中指定 order 參數,按照 crested_at 欄位的值排序:

order(:created_at)

不過,這樣的順序是升冪排序(ascending order),也就是由小到大,表示最舊的 microposts 會被排在前面。為了要降冪排列,我們使用 SQL 語法:

order('created_at DESC')

DESC 在 SQL 中表示降冪(descending),例如從最新排到最舊。

SQL 不分大小寫,但習慣使用大寫來表示 SQL 的關鍵字,像是 DESC

舊版 Rails 中,使用純 SQL 語法才能實現這個需求,不過在 Rails 4.0,我們可以使用純 Ruby 語法實現:

order(created_at: :desc)

幫 Micropost model 加上 default scope:

app/models/micropost.rb

class Micropost < ActiveRecord::Base
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

上述程式碼使用了「箭頭」語法(stabby lambda syntax),指向一個物件,這個物件稱為 Proc(procedure)或是 lambda,這是一個匿名函式(anonymous function),也就是沒有名字的函式。

stabby lambda -> 接受一個 block,然後回傳一個 Proc,這個 Proc 可以使用 call 方法來調用,執行其中的程式碼,我們可以透過 console 來看它是怎麼運作的:

>> -> { puts "foo" }
=> #<Proc:0x007fab938d0108@(irb):1 (lambda)>
>> -> { puts "foo" }.call
foo
=> nil

然後執行測試,會通過(Green):

$ bundle exec rake test

Dependent:destroy

除了正確的順序,我們還要改善一個東西,之前在設定管理員的時候,管理員有權刪除使用者,如果使用者被刪除了,那麼該名使用者的所有 microposts 也應該被刪除。

我們可以透過傳遞一個參數給 has_many 關聯方法來實現這個行為:

app/models/user.rb

class User < ActiveRecord::Base
  has_many :microposts, dependent: :destroy
  .
  .
  .
end

參數 dependent: :destroy 的作用就是當使用者自己被刪除的時候,依附在使用者上的 microposts 也會一併被刪除。這可以防止當管理員從系統刪除使用者時,資料庫不會出現沒有主人的 microposts。

我們可以在 User model 寫個測試來檢查這個行為,只要先儲存使用者(如此就會得到 id),然後建立與之關聯的 micropost,接著檢查刪除使用者之後,microposts 的數量有沒有少一個:

test/models/user_test.rb

require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "[email protected]",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "associated microposts should be destroyed" do
    @user.save
    @user.microposts.create!(content: "Lorem ipsum")
    assert_difference 'Micropost.count', -1 do
      @user.destroy
    end
  end
end

執行測試應該會通過(Green):

$ bundle exec rake test