Extracting Queries

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.where('published = ? AND published_on > ?', true, 2.days.ago)
  end
end

這是 controllers 內會常見的程式碼,以結果來說完全沒問題,但考量到維護性與可讀性,有可以改進的地方:

  • 在 controllers 內做了太多細節
  • 容易產生不必要重複的程式碼
  • 測試較難撰寫

改進後

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
     @posts = Post.recent
  end
end

# app/models/post.rb
class Post < ActiveRecord
  def self.recent
    where('published = ? AND published_on > ?', true, 2.days.ago)
  end
end

這是標準改進作法,使用了 ActiveRecord 的 scope 特性,有其他作法嗎?有的,可以使用以往 Class Methods 的方式

#app/models/post.rb
class Post < ActiveRecord
  def self.recent
    where('published = ? AND published_on > ?', true, 2.days.ago)
  end

  def self.by_author(author)
    where(author: author)
  end
end

一樣都是可以被串連起來使用的沒有問題!

author = "Carlos"
Post.by_author(author).recent
# SELECT "posts".* FROM "posts" WHERE "posts"."author" = 'Carlos' AND (published_on > '...')

那到底差在哪?嘗試考量更多的狀況##

假設說好的要找作者是誰,但是你不跟我說是誰怎麼辦?

# params[:author] #=> nil
Post.by_author(params[:author]).recent
# SELECT "posts".* FROM "posts" WHERE "posts"."author" IS NULL AND (published_on > '...')

# model 改寫,如果沒傳資料就回傳 nil
# app/models/post.rb
class Post < ActiveRecord
  def self.by_author(author)
    where(author: author) if author.present?
  end

  def self.recent
    where('published_on > ?', 2.days.ago)
  end
end

# 實際使用時若遇到 nil,就沒辦法串連 queries 了
# params[:author] #=> nil
Post.by_author(params[:author]).recent
# No MethodError: undefiend method 'recent' for NilClass

# 通常會試圖去改 model 的 class method 試圖不要讓 NilClass 錯誤發生
# app/models/post.rb
class Post < ActiveRecord
  def self.by_author(author)
    if author.present?
      where(author: author)
    else
      all # 沒資料就列出全部
    end
  end
end

看起來 class method 可以做到跟 scope 一樣的事情,還可依照需求提供 edge case 客製化的結果。但以團隊整體開發默契,以及 Rails Convention 來說,若非特殊狀況,還是建議乖乖使用 scope 就好,這樣一來一往就差了6行程式碼!

# app/models/post.rb
class Post < ActiveRecord
  scope :by_author, ->(author) { where(author: author) if author.present? }
  scope :recent, -> { where('published_on > ?', 2.days.ago) }
end

# scopes always return a chainable object
Post.by_author(nil).recent
# SELECT "posts".* FROM "posts" WHERE (published_on > '...')

更高階用法,合併 Scopes##

專案內絕對不只一個 Model 而已,Model 數量與程式碼重複的機率成正比,甚至可能會變成指數成長,還有招嗎?

class Comment < ActiveRecord
  belongs_to :post
  scope :approved, ->{ where(approved: true) }
end
# same query logic duplicated on two different models

class Post < ActiveRecord
  has_many :comments
  scope :with_approved_comments,
    -> { joins(:comments).where('comments.approved = ?', true) }
end
# "SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE ("comments"."approved" = 't')

這時候可以善用Merge的功能

# app/models/post.rb
scope :with_approved_comments,
    -> { joins(:comments).where('comments.approved = ?', true) }
# 改成用 merge 即可!
scope :with_approved_comments,
    -> { joins(:comments).merge(Comment.approved) }

Rails3 與 Rails4 的 Query Chaining 也不同!##

class User < ActiveRecord::Base
  scope :active,   -> { where(state: "active") }
  scope :inactive, -> { where(state: "inactive") }
end

# 如果是 Rails3,Query 接在一起會以最後一個為主
User.active.inactive # in Rails3
# SELECT * FROM users WHERE state = 'inactive'

# 如果是 Rails4,Query 接在一起會以 Append 為主
User.active.inactive # in Rails4
# SELECT * FROM posts WHERE state = 'active' AND state = 'inactive'

所以在 Rails4 若有 Query 可能會有衝突,也就是條件式的依據一樣,可以採用merge來做

User.active.merge(User.inactive)

整體來說 merge 這方法還是略嫌粗糙,不大符合 ruby 簡潔直觀的程式碼,有點曲折。如果可以是 User.active.inactive.merged 之類的,而不是帶參數進去的話就還不錯 XDDD