---
title: "在 Ruby 中找到特定資料後轉換回傳"
date: 2023-02-22T00:00:00+08:00
publishDate: 2023-02-22T10:40:49+08:00
lastmod: 2025-10-19T17:55:21+08:00
tags: ["Ruby","經驗","Enumerator","Functional"]
toc: true
permalink: "https://blog.aotoki.me/posts/2023/02/22/find-and-transform-data-in-ruby/"
language: "zh-tw"
---


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

<!--more-->

## 篩選並轉換

如果單純使用 `#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` 的資料作為替代，直到我們找到想要的資料為止。

## 直覺版本

比較直覺的版本大致上會像這樣，這裡是以現實的狀況作為例子，因此還有更多其他的限制存在。

```ruby
class ValueFinder
  # ...
  def execute
    node, = owner.nodes.recent.limit(1)
    return node.value if node.present?

    node, = ancestors.flat_map do |parent|
      node, = parent.nodes.recent.limit(1)
      node
    end.compact
    node&.value
  end

  def owner
    @owner ||= Owner.find(@owner_id)
  end

  def ancestors
    @ancestors ||= owner.ancestors(max_depth: 2)
  end
end
```

這裡其實有一些不同的限制，像是我們只會知道起點的 `@owner_id` 因此需要先找到起點，然後呼叫 `#ancestors` 方法找到上層的所有可用 Owner 物件。

接下來在篩選出「最新節點」的時候，我們會需要先對 `owner` 檢查一遍，然後再對 `ancestors` 檢查一遍，最後才回傳這個節點，整體上來說並不太直覺。

進一步修改，也許可以改善成這樣

```ruby
# ...
  def execute
    node, = [owner, *ancestors].flat_map do |parent|
      node, = parent.nodes.recent.limit(1)
      node
    end.compact
    node&.value
  end
# ...
```

然而我們最後拿到的還是一個陣列，同時需要提取出最初的那一個才行。

## 用 Reduce 轉換

在 Functional Programming 中常用的 `#reduce` 方法在這個情境其實可以發揮不錯的效果，印象中以前在跟人討論的時候還有過 `#map`、`#each` 這類行為都是由 `#reduce` 實現的說法。

```ruby
# ...
  def execute
    [owner, *ancestors].reduce(nil) do |res, current|
      node, = current.nodes.recent.limit(1)
      break node.value if node.present?
    end
  end
# ...
```

透過 `#reduce` 的方式，整個實作是不是更簡潔了呢？

除此之外，原本查詢最新節點的實作，可能可以使用 `#group` 來處理，然而大多數狀況下資料庫是無法接受沒有 Aggregate Function（e.g. `COUNT`）的狀態使用 `GROUP BY` 的。

```ruby
# ...
  def execute
    node, = Node.where(owner: [owner, *ancestors]).recent.group(:owner_id)
    node&.value
  end
# ...
```

即使我們可以正常查詢，這個實作會有一個問題，我們無法保證 `owner` 在回傳的順序是第一個，因此還需要另外做一次排序，才能使用這個回傳的資料。

```ruby
# ...
  def execute
    owners = [owner, *ancestors]
    nodes = Node.where(owner: owners).recent.group(:owner_id)
    node, = nodes.sort_by { |node| owners.index { |owner| owner.id == node.owner_id } }
    node&.value
  end
# ...
```

透過資料庫理論上效能會更好，然而在我的應用情況下，不是單純的 `node.value` 就可以解決的，還需要再做一次 `node.children` 的查詢，因此在合理的處理時間下採取了這樣的方式處理。

> 另外的限制是 `node.children` 這個查詢是一個[遞迴查詢](https://blog.aotoki.me/posts/2017/10/23/Use-PostgreSQL-s-recursive-query-to-find-ancestors/)，因此無法簡單的用 JOIN 之類的方式處理，是相當複雜的狀況。

