在 Ruby 中找到特定資料後轉換回傳
最近要實作一個功能,大致上是搜尋一群使用者符合條件的資料,然後回傳這筆資料下的另一個符合條件的資料,如果使用一般的方式來做,會需要分開撰寫查詢,並且多次的查詢,然而我們可以利用 Ruby 的語言特性來簡化這段程式。
篩選並轉換
如果單純使用 #find
方法可以找到特定的資料,然而我們還會需要先做一次 #map
才能找到我們想要的資訊,因此處理起來並不容易。
舉例來說,我們有幾筆資料是這樣的:
id | parent_id |
---|---|
1 | |
2 | 1 |
3 | 2 |
我們希望根據 階層順序,從 3
開始往回確認如果找到就回傳,反之繼續往 parent_id
繼續檢查,因此要回傳的資料如下。
owner_id | created_at | value |
---|---|---|
1 | 2023-01-01 | 1000 |
1 | 2023-02-01 | 900 |
2 | 2023-01-02 | 800 |
2 | 2023-02-02 | 700 |
3 | 2023-01-03 | 600 |
3 | 2023-02-03 | 500 |
以這個例子來說,我們會先找到 owner_id = 3
的兩筆資料,然後選擇比較新的 500
回傳,如果 owner_id = 3
沒有任何資料,那麼就會去找 owner_id = 2
的資料作為替代,直到我們找到想要的資料為止。
直覺版本
比較直覺的版本大致上會像這樣,這裡是以現實的狀況作為例子,因此還有更多其他的限制存在。
1class ValueFinder
2 # ...
3 def execute
4 node, = owner.nodes.recent.limit(1)
5 return node.value if node.present?
6
7 node, = ancestors.flat_map do |parent|
8 node, = parent.nodes.recent.limit(1)
9 node
10 end.compact
11 node&.value
12 end
13
14 def owner
15 @owner ||= Owner.find(@owner_id)
16 end
17
18 def ancestors
19 @ancestors ||= owner.ancestors(max_depth: 2)
20 end
21end
這裡其實有一些不同的限制,像是我們只會知道起點的 @owner_id
因此需要先找到起點,然後呼叫 #ancestors
方法找到上層的所有可用 Owner 物件。
接下來在篩選出「最新節點」的時候,我們會需要先對 owner
檢查一遍,然後再對 ancestors
檢查一遍,最後才回傳這個節點,整體上來說並不太直覺。
進一步修改,也許可以改善成這樣
1# ...
2 def execute
3 node, = [owner, *ancestors].flat_map do |parent|
4 node, = parent.nodes.recent.limit(1)
5 node
6 end.compact
7 node&.value
8 end
9# ...
然而我們最後拿到的還是一個陣列,同時需要提取出最初的那一個才行。
用 Reduce 轉換
在 Functional Programming 中常用的 #reduce
方法在這個情境其實可以發揮不錯的效果,印象中以前在跟人討論的時候還有過 #map
、#each
這類行為都是由 #reduce
實現的說法。
1# ...
2 def execute
3 [owner, *ancestors].reduce(nil) do |res, current|
4 node, = current.nodes.recent.limit(1)
5 break node.value if node.present?
6 end
7 end
8# ...
透過 #reduce
的方式,整個實作是不是更簡潔了呢?
除此之外,原本查詢最新節點的實作,可能可以使用 #group
來處理,然而大多數狀況下資料庫是無法接受沒有 Aggregate Function(e.g. COUNT
)的狀態使用 GROUP BY
的。
1# ...
2 def execute
3 node, = Node.where(owner: [owner, *ancestors]).recent.group(:owner_id)
4 node&.value
5 end
6# ...
即使我們可以正常查詢,這個實作會有一個問題,我們無法保證 owner
在回傳的順序是第一個,因此還需要另外做一次排序,才能使用這個回傳的資料。
1# ...
2 def execute
3 owners = [owner, *ancestors]
4 nodes = Node.where(owner: owners).recent.group(:owner_id)
5 node, = nodes.sort_by { |node| owners.index { |owner| owner.id == node.owner_id } }
6 node&.value
7 end
8# ...
透過資料庫理論上效能會更好,然而在我的應用情況下,不是單純的 node.value
就可以解決的,還需要再做一次 node.children
的查詢,因此在合理的處理時間下採取了這樣的方式處理。
另外的限制是
node.children
這個查詢是一個遞迴查詢,因此無法簡單的用 JOIN 之類的方式處理,是相當複雜的狀況。