一般來說在 Rails 開發上,Active Record 已經算是相當成熟的 ORM 來做 SQL query 的封裝
除非極其複雜的 Query 無法用 Rails 寫出來亦或是團隊每個人都是 SQL query 高手,否則建議在團隊中我都建議用比較 Rails 的風格撰寫 Query,在後續維護上除了語意化以外也較容易做測試。
畢竟要看懂不同人寫的 query 還是相對花時間的,尤其散落一地獨樹一格的 SQL Query 當資料庫版本升級時或是 Rails 升級時會是一個頭痛的問題。
這篇文章會介紹 5 個簡單的整理技巧,而技巧因實際遭遇情況複雜度循序漸進
1. Model Scope
基本上使用這個基本招保持乾淨都能夠寫出能維護的 query
# 未帶參數
scope :published, -> { where(published: true) }
# 帶參數
scope :created_before, ->(time) { where("created_at < ?", time) }
串接在一起打組合技
Post.published.created_before(Time.now)
scope 回傳出來的結果一定要是 Active Record Relation,因為 scope 是讓你串接 query 使用的
如果你不確定,你可以加上 .class
來進行確認,例如
Post.recent.class
# Post::ActiveRecord_Relation
在這邊新手常犯的有以下兩種錯誤
a. 把 scope 當 method 來使用
# Bad
# 不可以把 scope 當 method 使用
scope :latest, -> { order(id: :desc).first }
# Good
# 如果要用,請保持 scope 的輸出必為 ActiveRecord Relation
scope :recent, -> { order(id: :desc) }
# 並另外呼叫
Post.recent.first
b. 回傳與當前 model 無關的 relation
# Bad
# 不可以回傳與當前 model 無關的 relation
# 在 User model 中的 scope 不能回傳 Post 的 relation
scope :famous_posts, ->(user_id) { Post.where("user_id = ? AND view_count > ?", user_id, 1_000) }
# Good
# 真的要這麼做,請拆 class method
# User model
def self.famous_posts(user_id)
Post.where("user_id = ? AND view_count > ?", user_id, 1_000")
end
2. Relation merge
依照關連的 table 做條件篩選
以 User has one Profile 的關連來說,假設我們要知道 User 是不是 active 需要依照 profile 中的 activated 欄位來決定
可能會這樣寫
# User model
scope :activated, ->{ joins(:profile).where(profiles: { activated: true }) }
但這樣的壞處是,其實你不應該把 profile 的邏輯放在 user 裡面
更好的作法應該是分別放兩個 model 裡面單獨寫 scope 然後用 merge 來拼出 SQL 語句
# Profile model
scope :activated, ->{ where(activated: true) }
# User model
scope :activated, ->{ joins(:profile).merge(Profile.activated) }
依照關連的 table 做排序
跨表的排序也可以用 Relation#merge
來做
情境是
- User has many Post
- Post has many Like
如果我們要依照 User 曾經發過所有的 Post 的所有 Like 總數最高進行倒序排序,要如何做呢?
你可能會直接硬刻 SQL?
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" INNER JOIN "likes" ON "likes"."post_id" = "posts"."id" GROUP BY "users"."id" ORDER BY count(posts.id) DESC
或是稍微拼出來?
User.joins([posts: :likes]).group(:id).order("count(posts.id) DESC")
我覺得更易維護的作法是拆 scope + merge
# Post model
scope :sort_by_likes_count, -> { joins(:likes).group(:id).order("count(posts.id) DESC") }
# User controller
User.joins(:post).merge(Post.sort_by_likes_count)
3. Exist query
這裡指的不是 .exists?
假設有一個情況是要撈出那些 Post 點閱數較低的 User,可以用 NOT EXISTS
的 query 來進行搜尋
# Post
scope :famous, ->{ where("view_count > ?", 1_000) }
# User
scope :without_famous_post, ->{
where(_not_exists(Post.where("posts.user_id = users.id").famous))
}
def self._not_exists(scope)
"NOT #{_exists(scope)}"
end
def self._exists(scope)
"EXISTS(#{scope.to_sql})"
end
這是我在另一篇文章看到之前沒想過的方法,感覺可以把這些 method 拆成 concern 用 include 的方式來使用會更好些
不過也已經有人做過類似的 Gem activerecord_where_assoc
4. Subqueries
Post.where(user_id: User.created_last_month.pluck(:id))
這樣會跑出兩條 SQL,因為他會先把資料拉出來做 pluck ,將 id 塞到 array 後再進行第二條 query 的查詢
但其實可以只打一條 query
Post.where(user_id: User.created_last_month)
基本上只要確保裡面送進去的值也是 relation,就可以讓 ActiveRecord 來幫你處理
有時候拆兩條 query 甚至會比打 subquery 來的快,這部分就要看情況來做決定了,可能取決於你的 data set 大小 / index 等等
5. Query Object
通常會用到 query object 是比較複雜的查詢或排序,一般來說上述的技巧都已經夠用,可以在符合需求的情況下不把 code 搞得太複雜
然而 query object 要解決的事情會是
- 抽象出複雜的查詢或排序
- 讓複雜的 query 是可以 testable
假設我們希望有個 class 可以幫我處理好排序的 Post,而無論那個條件都必須先依照按讚數(Like) 來做排序
class OrderedPostsQuery
SORT_OPTIONS = %w(by_date by_title).freeze
def initialize(params = {}, relation = Post.all)
@relation = relation.includes(:likes).extending(Scopes)
@sort = params[:sort].presence_in(SORT_OPTIONS)
@direction = params[:direction]
end
def all
@relation = @relation.by_likes_count
@relation = case @sort
when "by_title"
@relation.by_title
when "by_date"
@relation.by_date
end
@relation
end
module Scopes
def by_title
order(title: @direction)
end
def by_date
order(created_at: @direction)
end
def by_likes_count
group(:id).order("count(posts.id) DESC")
end
end
end
在 controller 就可以這樣呼叫,依照 title 又是按讚數最高的排序
@posts = OrderedPostsQuery.new({ sort: :by_title, direction: :desc }).all