使用 Ransack 幫你完成後端搜尋與 Sorting
為什麼用 Ransack
在網站中提供搜尋、篩選與排序功能在現在的網站中已是基本需求,畢竟大家的耐心,在 Ruby on Rails 中,由於採用了 ActiveRecord 作為 ORM 框架的緣故,要快速的組出 Search Query 並非難事。
基本上只要搭配上 Form Object 與 Query Object 模式,要優雅的完成好維護的搜尋模組還算是容易,但當你想要快速完成搜尋功能的時候,Ransack 絕對是你的好夥伴。
基本使用
Basic Configuration
Ransack 的基本設定極其簡單,只要安裝完後基本上就可以使用了。
- 首先,在你的 Gemfile 中加入:
# Gemfile
gem 'ransack'
- 接著在 Terminal 中執行
bundle install
完成了之後打開 Console,你就可以盡情的使用
ransack
提供的 Matchers 來組合出搜尋語句了,範例如下:- 找出使用者名字是 y 開頭的使用者:(使用
start
matcher )
>> query = User.ransack(name_start: 'y') #=> Ransack::Search<class: User, base: Grouping <conditions: [Condition <attributes: ["name"], predicate: start, values: ["y"]>], combinator: and>> >> query.result #=> User Load (0.5ms) SELECT "users".* FROM "users" WHERE "users"."login_name" ILIKE 'y%'
- 上述的簡單範例中,我們可以看到 Ransack 幫我們在 ActiveRecord Model 中擴充了
.ransack
方法,只要在其中傳入查詢參數就行了 - 呼叫
ransack
後,回傳的是一個Ransack::Search
實體,可以看到我們輸入的查詢條件都已經被成功解析,預設使用AND
來合併不同的查詢條件。 - 對
Ransack::Search
實體呼叫#result
就能得到查詢結果 ransack
方法接受的查詢參數是一個Hash
,Hash
的key
形式為AttrName_MatcherName
。可以同時使用多個查詢條件,如:- 找出標題含有
Bitcoin
或Ethereum
關鍵字、並且超過 5,000 點閱率的文章
>> query = Blog.ransack(title_cont_any: ['bitcoin', 'Ethereum'], view_count_gt: 5000) #=> Ransack::Search<class: Blog, base: Grouping <conditions: [Condition <attributes: ["title"], predicate: cont, values: ["bitcoin"]>, Condition <attributes: ["view_count"], predicate: gt, values: [500]>], combinator: and>> >> query.result #=> SELECT `blogs`.* FROM `blogs` WHERE (`blogs`.`title` LIKE '%bitcoin%' OR `blogs`.`title` LIKE '%Ethereum%') AND `blogs`.`view_count` > 5000
- 找出標題含有
當然查詢策略可以自行決定要用
AND
來確保符合所有查詢條件才返回結果;或用OR
來指定只要任一條件符合就返回結果,這部分可透過 combinator 的設定來達成 (combinator 的設定可用m
為別名),如:- 尋找標題或是內文有 Bitcoin 關鍵字的文章
>> query = Blog.ransack(title_cont: 'Bitcoin', content_cont: 'Bitcoin', m: :or) #=> Ransack::Search<class: Blog, base: Grouping <conditions: [Condition <attributes: ["title"], predicate: cont, values: ["Bitcoin"]>, Condition <attributes: ["content"], predicate: cont, values: ["Bitcoin"]>], combinator: or>> >> query.result #=> Blog Load (12.6ms) SELECT `blogs`.* FROM `blogs` WHERE ` `blogs`.`title` LIKE '%Bitcoin%' OR `blogs`.`content` LIKE '%Bitcoin%'
- 找出使用者名字是 y 開頭的使用者:(使用
Ransack 提供了超過五十個預先定義好的 Matchers,透過欄位與 Matchers 的結合幾乎已經可以完成所有常見的查詢,這邊就不嘗試一一列舉了,更詳細的文件請參見官方 README 關於 Search Matchers 的篇幅。
Association Filter
除了對 Model 物件本身的屬性進行查詢之外,Ransack 同時也支援了對關聯物件屬性的查詢,例如: - 找出作者名字是 Yusheng
,且標題含有 Rails
關鍵字的部落格文章
>> query = Blog.ransack(author_name_eq: 'Yusheng', title_cont: 'Rails')
#=> Ransack::Search<class: Blog, base: Grouping <conditions: [Condition <attributes: ["author_name"], predicate: matches, values: ["Yusheng"]>, Condition <attributes: ["title"], predicate: cont, values: ["Rails"]>], combinator: and>>
>> query.result
#=> Blog Load (0.5ms) SELECT `blogs`.* FROM `blogs` LEFT OUTER JOIN `authors` ON `authors`.`id` = `blogs`.`author_id` WHERE `authors`.`name` LIKE 'Yusheng' AND `blogs`.`title` LIKE '%Rails%'
- 這邊可以看到當我對 Blog 身上的 Author 關聯做查詢時,Ransack 會自動
LEFT OUTER JOIN
Author 資料,並在一個 Query 中完成整個查詢。
Scope Filter
除了上述的基本屬性搜尋、關聯屬性查詢外,Ransack 也支援透過 Model Scope 來做查詢,假設在 Model 中我們有一個 Scope :published_since
定義如下:
# app/models/blog.rb
class Blog
# ...
scope :published_since, -> (date) { where(published: true).where('published_date <= ?', date) }
# ...
end
此時我們可以透過 :published_since
這個 scope 來做查詢,如:
>> Blog.ransack(published_since: Date.today).result
#=> Blog Load (6.1ms) SELECT `blogs`.* FROM `blogs` WHERE `blogs`.`published` = 1 AND (published_date < '2018-05-01')
- 這邊要特別注意的是,預設上 Ransack 必須要將可用於搜尋的 Scope 加入
.ransackable_scopes
白名單中,否則會被 Ransack 直接忽略,詳細可以看下面關於 Ransack Authorization 的部分。
Built-in Form Helper
Ransack 除了對 ActiveRecord Model 擴充了 ransack
方法,給了我們一個好用的查詢介面之外,同時他也提供了我們 FormHelper 的擴充,讓我們可以輕易的在 View 當中建出搜尋表單。例如為上述的部落格搜尋建出表單:
# app/views/blogs/index.html.erb
# ...
<%= search_form_for @q do |f| %>
<%= f.label :title_cont %>
<%= f.search_field :title_cont %>
<%= f.label :content_cont %>
<%= f.search_field :content_cont %>
<%= f.label :author_name_cont %>
<%= f.search_field :author_name_cont %>
<%= f.submit %>
<% end %>
# ...
之後你只需要在 BlogController
進行類似如下的設定,搜尋功能就這麽簡單地完成了。
def index
@q = Blog.ransack(params[:q])
@blogs = @q.result
end
如果你跟我一樣是 SimpleForm 的愛好者,Ransack 當然也有對 SimpleForm 提供支援,詳情可以看官方建議的設定方式。
Ransack 預設使用
params[:q]
來存放查詢條件,如果你需要客製、或是你在同一個頁面有多個對於不同資源的搜尋表單,可以進一步閱讀 Ransack Wiki 中的 Configuration 條目來了解如何對:search_key
進行設定。
Sort Links Helpers
除了搜尋之外,Ransack 也提供了 Toggler Sort Order 的功能,例如我想要提供一個連結,讓使用者可以將部落格文章依照發布日期排序,這時就可以借助 Ransack 提供的 sort_link
方法:
<%= sort_link(@q, :publish_date, '發布日期', default_order: :desc) %>
當使用者按下該連結時,就會在依照發布日期 升冪 / 降冪 排序中切換。
Alias
上述部落格搜尋的範例中,實務上我們可能只會提供一個部落格關鍵字搜索的欄位給使用者,只要部落格標題、或內文含有該關鍵字就將結果返回,這時我們的 Query 可能如下:
Blog.ransack(title_or_content_cont: 'Rails')
但 :title_or_content_cont
這 Key Name 實在又臭又長,這時可以透過 ransack_alias
來為查詢屬性設定別名。
# app/models/blog.rb
class Blog
# ...
ransack_alias :article, :title_or_content
# ...
end
此時你就可以用 :article_cont
來處理剛剛的搜尋,例如:
Blog.ransack(article_cont: 'Rails').result
Authorization
上述大致上講了 Ransack 的 基本查詢用法,但有些時候我們會不希望某些敏感屬性可被使用者查詢、或是只允許某些具有特定權限的使用者可以查詢,Ransack 也提供了四個方法讓我們針對需求進行設定:
ransackable_attributes
- 定義可被作為搜尋條件的欄位,若查詢欄位不在此列表中,該查詢條件會被自動無視
- 預設所有欄位皆可被搜尋
- 原本的實作如下:
def ransackable_attributes(auth_object = nil)
column_names + _ransackers.keys
end
- 所以,假設 User 物件可被一般用戶搜尋的欄位僅包括 Email, Name 等,可在 User Model 中覆寫該類別方法,如:
# app/models/user.rb
class User
# ...
def self.ransackable_attributes(auth_object = nil)
if auth_object == :admin
super
else
super & %w(email name)
end
end
# ...
end
- 接著在 Controller 中你可以透過傳入
auth_object
來決定該使用者有權搜索的欄位
# app/controllers/users_controller.rb
class UsersController < ActionController::Base
# ...
def index
@q = User.ransack(params[:q], auth_object: current_user.role.to_sym)
@users = @q.result
end
# ...
end
ransackable_associations
:
- 定義可作為搜尋條件的關聯
- 預設返回該 Model 所擁有的所有關聯
- 原本的實作如下:
def ransackable_associations(auth_object = nil)
reflect_on_all_associations.map { |a| a.name.to_s }
end
ransortable_attributes
:
- 定義可作為排序條件的欄位
- 預設所有的欄位皆可被用做排序
- 原本的實作如下:
def ransortable_attributes(auth_object = nil)
ransackable_attributes(auth_object)
end
ransackable_scopes
:
- 定義可作為搜尋條件的 Model Scope
- 預設返回空陣列,所以若希望 Model Scope 可被用於搜尋,必須於 Model 覆寫該方法、加入白名單中
- 原本的實作如下
def ransackable_scopes(auth_object = nil)
[]
end
Advanced Usage
上面講了 Ransack 的常用的功能及設定,但很多時候因為想要使用特定資料庫提供的方法、或是想要 Custom Query 作為查詢條件時該怎麼辦呢?
除了基本的 Matchers、Scope、Association 可被用作搜尋外,Ransack 也提供了自訂 Predicate、Ransaker 的方法讓你去擴充查詢條件,由於 Ransack 實作上是使用了 Arel 的 Predicate 來組合出查詢語句,只要對 Arel 有基本了解就可以任意的對 Ransack 的 Predicate 進行擴充、或是新增 Ransacker 來擴充可查詢條件。
以下簡單示範在使用 MySQL 資料庫的情況下,為查詢擴充 Case Insensitive Search (如:Postgres 的 ILIKE
):
在 MySQL 中,要進行 Case Insensitive Search 的 Query 大致如下:
SELECT `blogs`.* FROM `blogs` WHERE (lower(blogs.title) LIKE '%bitcoin%')
- 首先,必須要先透過 SQL 方法將要查詢的欄位轉換成小寫
- 送入的查詢值也必須要轉換成小寫
- 如此不管查詢的值大小寫與資料庫內記錄是否完全符合,只要拼法一樣便可以成功返回結果
Ransacker
可以理解成可查詢條件的擴充,此例中,我們要將 title、content 欄位轉換為小寫好進行 Case Insensitive Search。
一般來說,Ransacker 被預期回傳 Arel 節點來搭配 Predicate 方法 (Matchers) 一起使用。
# app/models/blog.rb
class Blog
# ...
ransacker :lower_title do
Arel.sql('lower(title)')
end
ransacker :lower_content do
Arel.sql('lower(content)')
end
# ...
end
到這邊,我們已經透過剛剛新增的 ransacker 搭配 Matchers 進行查詢:
Blog.ransack(lower_title_cont: 'bitcoin').result
#=> SELECT `blogs`.* FROM `blogs` WHERE (lower(blogs.title) LIKE '%bitcoin%')
但是這樣還有一點小缺憾,如果使用者丟進來的查詢條件是 Bitcoin
,就會無法正確的達成 Case Insensitive Search,我們可以透過 Custom Predicate 來修正這個問題。
Custom Predicates
可以想成自訂的 Matchers,其中 Formatter 可以將收到的查詢條件進行轉換,此例中我們預期不論使用者丟進來的查詢條件大小寫形式如何,一律轉換為小寫去做轉換
擴充 Predicate 的方式是對 Ransack 進行設定,一般來說我們會在 Initializers 裡面完成設定:
# config/initializers/ransack.rb
Ransack.configure do |config|
config.add_predicate :cont_downcase,
arel_predicate: :matches,
formatter: proc { |v| "%#{v.to_s.downcase}%" },
validator: proc { |v| v.present? },
compounds: true,
type: :string
end
arel_predicate
必須要是一個合格的 Arel Predicates,完整的列表可以參閱 Arel 文件validator
內的條件若不符合,該查詢條件會被直接忽略
至此,我們可以順利的在 MySQL 中達成 Case Insensitive Search 的功能:
Blog.ransack(lower_title_cont_downcase: 'BITCOIN').result
#=> SELECT `blogs`.* FROM `blogs` WHERE (lower(blogs.title) LIKE '%bitcoin%')
小結
使用 Ransack 作為 Database Search 的解決方案,不僅設定 / 使用容易,由於背後使用 Arel 來組出查詢語句的關係,也擁有極強的擴充能力。雖然現在有許多人喜歡用 ElasticSearch 來完成搜尋功能,但是基本的篩選、查詢功能使用 Ransack 便已足矣,若沒有需要 Fuzzy Search、中文斷詞的功能,實在無需多開服務浪費機器的效能。
Ransack 不但有簡單易用的介面、也考量到了權限、可擴充性、I18n 等基本要求,算是一個相當完善的解決方案,同時文件完整、也處於積極的維護狀態中,下次若要實作搜尋功能不妨可以考慮看看。