五倍紅寶石・專業程式教育

五倍紅寶石 ・專業程式教育機構

使用 Ransack 幫你完成後端搜尋與 Sorting

By Yusheng・5月 31 2019・技術文章
使用 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 等基本要求,算是一個相當完善的解決方案,同時文件完整、也處於積極的維護狀態中,下次若要實作搜尋功能不妨可以考慮看看。


👩‍🏫 課務小幫手:

✨ 想掌握 Ruby on Rails 觀念和原理嗎?

我們有開設 🏓 Ruby on Rails 實戰課程 課程唷 ❤️️