iT邦幫忙

2021 iThome 鐵人賽

0
Modern Web

初階 Rails 工程師的養成系列 第 31

Day31. Rails 搜尋的強大幫手 - Ransack

ransack是一個基於Model層級的Gem,使用Ransack,可以將原本的Sql Statement寫得更簡潔。RansackRails數一數二好用的Gem,也因此有許多人幫忙為ransack 背書。

Ransack 會依據傳過來的參數,自動SubQueryJoinPreload,因此複雜的問題我們可以使用Ransack語句來處理,更複雜的問題,或者enum的查詢,可以交給自定義的Ransacker,在Ransacker裡頭使用ransack自定義搜尋,做成更複雜的搜尋系統。

使用ransack,就像是使用sql statement一樣,我們只要知道會遇到哪些雷,知道哪些雷不要踩後,就可以很舒服的使用。

Scenario

查找資料最需要用實例來解釋,以下列出幾種曾經幾個使用過的ransack語句。

商品名稱含關鍵字「釉彩」

  • _cont 為模糊搜尋的意思
Product.ransack(title_zh_cont: '釉彩').result
SELECT `products`.* FROM `products` WHERE `products`.`title_zh` LIKE '%釉彩%' LIMIT 11

子訂單關聯商品

搜尋子訂單內的訂單項目對應的商品名稱

  • 子訂單關聯variantvariant 又關聯product,而我們要找producttitle_zh欄位,因此寫成variant_product_title_zh_cont
  • ransack自動幫我們用left_join起來
sub_order.order_items.ransack(variant_product_title_zh_cont: '珍珠').result
SELECT `order_items`.* FROM `order_items` LEFT OUTER JOIN `variants` ON `variants`.`id` = `order_items`.`variant_id` LEFT OUTER JOIN `products` ON `products`.`id` = `variants`.`product_id` WHERE `order_items`.`sub_order_id` = 1 AND `products`.`title_zh` LIKE '%珍珠%' LIMIT 11

找尋特定訂單

找尋一個禮拜前創建的訂單、不能有任一張退貨單為申請中、不能為全部的退貨單狀態為「審查不同意」

  • _does_not_match_any:沒有任何一個符合
  • _does_not_match_all:沒有全部都符合
  • lt/gt/lteq/ gteq:小於、大於、小於等於、大於等於
Order.instance_eval do
  # 排除尚在審查的退貨單的訂單
  scope :return_order_reviewed, -> { ransack(return_orders_status_does_not_match_any: :applied).result }
  # 一個禮拜前
  scope :week_ago, -> { ransack(done_at_lt: Tool.appreciation_period.ago).result }
  # 若該訂單底下的所有退貨單審查不通過,則不用同步pos
  scope :not_all_return_failed, -> { ransack(return_orders_status_does_not_match_all: :return_failed).result }
end

Order.return_order_reviewed.week_ago.not_all_return_failed
SELECT `orders`.* FROM `orders` WHERE `orders`.`id` NOT IN (SELECT `return_orders`.`order_id` FROM `return_orders` WHERE `return_orders`.`order_id` = `orders`.`id` AND NOT ((`return_orders`.`status` NOT LIKE 0))) AND `orders`.`done_at` < '2021-09-27 14:54:23' AND `orders`.`id` NOT IN (SELECT `return_orders`.`order_id` FROM `return_orders` WHERE `return_orders`.`order_id` = `orders`.`id` AND NOT ((`return_orders`.`status` NOT LIKE 4))) LIMIT 11

購物車(Cart) & 購物車項目(CartItem)

以下為判斷最新購物車內物品是否為空,以及車裡面的項目是否在1天內更新過的語法。由SQL Statement可以知道,兩者的意思相同,cart_items_not_null: true 是多餘的,理由也很好理解,因為沒有購物車項目就不會找到1天內更新過的購物車項目

  • _null:空值
  • _not_null:非空值
Cart.ransack(cart_items_updated_at_lteq: 1.days.ago, cart_items_not_null: true).result.to_sql
#=> "SELECT `carts`.* FROM `carts` LEFT OUTER JOIN `cart_items` ON `cart_items`.`cart_id` = `carts`.`id` WHERE `cart_items`.`updated_at` <= '2021-09-08 06:10:04.641275'"

Cart.ransack(cart_items_updated_at_lteq: 1.days.ago).result.to_sql
#=> "SELECT `carts`.* FROM `carts` LEFT OUTER JOIN `cart_items` ON `cart_items`.`cart_id` = `carts`.`id` WHERE `cart_items`.`updated_at` <= '2021-09-08 06:10:14.869951'"

另外關於這台車,還有值得討論的地方。

以下轉換為Sql Statement時,使用的是LEFT (OUTER) JOIN語法 ,上一回提過搜尋的結果會造成找出的結果有重複的筆數,因此我們需要用distinct語法來濾除。當時購物車沒有使用distinct 而導致對某使用者重複推播,所以回過頭還是要注意Sql Statement是否會有找到重複資料的問題

Cart.ransack(cart_items_updated_at_gt: 1.days.ago, customer_id_null: false).result.count
#=>   (1.1ms)  SELECT COUNT(*) FROM `carts` LEFT OUTER JOIN `cart_items` ON `cart_items`.`cart_id` = `carts`.`id` WHERE (`cart_items`.`updated_at` > '2021-09-23 23:29:34.891970' AND `carts`.`customer_id` IS NOT NULL)
#=> 10 

Cart.ransack(cart_items_updated_at_gt: 1.days.ago, customer_id_null: false).result.uniq.count
#=>  Cart Load (2.7ms)  SELECT `carts`.* FROM `carts` LEFT OUTER JOIN `cart_items` ON `cart_items`.`cart_id` = `carts`.`id` WHERE (`cart_items`.`updated_at` > '2021-09-23 23:29:42.348931' AND `carts`.`customer_id` IS NOT NULL)
#=> 2 

#============== 使用distinct
Cart.ransack(cart_items_updated_at_gt: 1.days.ago, customer_id_null: false).result.distinct.count
#=>   (4.6ms)  SELECT COUNT(DISTINCT `carts`.`id`) FROM `carts` LEFT OUTER JOIN `cart_items` ON `cart_items`.`cart_id` = `carts`.`id` WHERE (`cart_items`.`updated_at` > '2021-09-23 23:38:08.748560' AND `carts`.`customer_id` IS NOT NULL)
#=> 2 

⭐️ ⭐️ ⭐️ ⭐️ Ransack所需要知道的雷,只會發生於1對多has_many,當被left_join, join時可能會出現重複筆以外,其他用法基本上都毋需太過擔心。

列表 DataTable

⭐️ 搜尋條件有開始、結束時間、關鍵字、下拉式

https://ithelp.ithome.com.tw/upload/images/20210927/20115854lnkoaACaus.png

SELECT `blogs`.* FROM `blogs` WHERE ((`blogs`.`title` LIKE '%標題%' OR `blogs`.`content` LIKE '%標題%') AND `blogs`.`created_at` >= '2020-09-26 16:00:00' ANlogs`.`created_at` < '2021-09-27 16:00:00') LIMIT 10 OFFSET 0

⭐️ 搜尋條件只有開始和結束時間和關鍵字

https://ithelp.ithome.com.tw/upload/images/20210927/20115854rgmUlyeb6C.png

SELECT `blogs`.* FROM `blogs` WHERE ((`blogs`.`title` LIKE '%標題%' OR `blogs`.`content` LIKE '%標題%') AND `blogs`.`created_at` >= '2020-09-26 16:00:00' ANlogs`.`created_at` < '2021-09-27 16:00:00') LIMIT 10 OFFSET 0

⭐️ 搜尋條件只有開始和結束時間

https://ithelp.ithome.com.tw/upload/images/20210927/20115854SD33R9y3yp.png

SELECT `blogs`.* FROM `blogs` WHERE (`blogs`.`created_at` >= '2020-09-26 16:00:00' AND `blogs`.`created_at` < '2021-09-27 16:00:00') LIMIT 10 OFFSET 0

⭐️ ⭐️ ⭐️ ⭐️ 前面提到過,只要下的關聯為belongs_to, has_one,都不用擔心會有查詢重複筆數的問題。

實際退貨金額小於等於0,或者為空值

ReturnOrder.ransack(m: 'or', return_actual_rebate_amount_lteq: 0, return_actual_rebate_amount_null: true).result

ransack_alias

class Post < ActiveRecord::Base
  belongs_to :author

  # Abbreviate :author_first_name_or_author_last_name to :author
  ransack_alias :author, :author_first_name_or_author_last_name
end

ransacker

ransack 搭配 enum 的用法

# enum
class SubOrder < ApplicationRecord  
   enum sex: { male: 'M', female: 'F', unavailable: 'N' }
  ransacker :sex, formatter: proc { |v| sexes[v] }
  # ransacker :vip_level, formatter: proc { |v| VIP_LEVEL_TRANS_MAP[v] }
end 
  
# 使用 ransacker 以前
SubOrder.ransack(sex: sexes[:male])  
# 使用 ransacker 以後
SubOrder.ransack(sex: :male) 

我們也可以自定義 ransack

class SubOrder < ApplicationRecord  
  scope :unshipped, -> (tab) do
    if tab.in? [:waiting, 'waiting']
      ransack(status_eq: Status::PROCESSING, shipping_status_eq: ShippingStatus::READY).result
    elsif tab.in? [:failed, 'failed']
      ransack(status_not_eq: Status::DONE, shipping_status_eq: ShippingStatus::FAILED).result
    else
      SubOrder.all
    end
  end

  def self.ransackable_scopes(_auth_object = nil)
    [:unshipped]
  end
end

SubOrder.ransack(unshipped: :waiting).result.to_sql
#=> "SELECT `sub_orders`.* FROM `sub_orders` WHERE (`sub_orders`.`status` = 'processing' AND `sub_orders`.`shipping_status` = 'ready')"
class ReturnOrder < ApplicationRecord 
  enum status: %i(applied processing refund_failed return_failed done)
  ransacker :status, formatter: proc { |v| statuses[v] }
    
  # 待處理/退貨失敗/全部退貨單  
  scope :tab, -> (tab) do
    if tab.in? [:all, 'all']
      all
    elsif tab.in? [:return_failed, 'return_failed']
      where(status: :return_failed)
    else
      where(status: :processing)
    end
  end

  def self.ransackable_scopes(_auth_object = nil)
    [:tab]
  end
end

ReturnOrder.ransack(tab: :processing).result
#=> SELECT `return_orders`.* FROM `return_orders` WHERE `return_orders`.`status` = 1 LIMIT 11

ransackable_attributes

我們可以透過ransackable_attributes,搜尋該model可以使用的ransack屬性。

> SubOrder.ransackable_attributes
#=> ["id", "brand_id", "order_id", "created_at", "updated_at", "number", "store_id", "shipping_status", "shipped_at", "arrived_at", "received_at", "status_manual_changed", "shipping_status_manual_changed", "failed_at", "remark", "status", "shipments_count", "pickup_personally", "admin_force_return"]

結尾語

原本打算將Gem相關的文章集結成系列介紹,但因在datatable使用了很多次,加上我也答應我的同事要寫一篇文介紹ransack,因此有這篇文章的誕生。

參考資料


上一篇
Day30. Model 與關聯 - preload, join, includes 一次釐清
下一篇
Day32. 使用Decorator Pattern 實作攤提
系列文
初階 Rails 工程師的養成34

尚未有邦友留言

立即登入留言