使用 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 方法接受的查詢參數是一個 HashHashkey 形式為 AttrName_MatcherName。可以同時使用多個查詢條件,如:

      • 找出標題含有 BitcoinEthereum 關鍵字、並且超過 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%'
      
  • 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 等基本要求,算是一個相當完善的解決方案,同時文件完整、也處於積極的維護狀態中,下次若要實作搜尋功能不妨可以考慮看看。