蒼時弦也
蒼時弦也
資深軟體工程師
發表於

在 Ruby 中找到特定資料後轉換回傳

最近要實作一個功能,大致上是搜尋一群使用者符合條件的資料,然後回傳這筆資料下的另一個符合條件的資料,如果使用一般的方式來做,會需要分開撰寫查詢,並且多次的查詢,然而我們可以利用 Ruby 的語言特性來簡化這段程式。

篩選並轉換

如果單純使用 #find 方法可以找到特定的資料,然而我們還會需要先做一次 #map 才能找到我們想要的資訊,因此處理起來並不容易。

舉例來說,我們有幾筆資料是這樣的:

idparent_id
1
21
32

我們希望根據 階層順序,從 3 開始往回確認如果找到就回傳,反之繼續往 parent_id 繼續檢查,因此要回傳的資料如下。

owner_idcreated_atvalue
12023-01-011000
12023-02-01900
22023-01-02800
22023-02-02700
32023-01-03600
32023-02-03500

以這個例子來說,我們會先找到 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 之類的方式處理,是相當複雜的狀況。