Raiils4 Patterns II: Class Methods and Scopes
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