---
title: "加入聚合實體 - Rails 開發實踐"
date: 2023-09-29T00:00:00+08:00
publishDate: 2023-09-29T00:00:00+08:00
lastmod: 2023-09-03T17:33:12+08:00
tags: ["經驗","心得","Rails","Rails 開發實踐"]
series: "rails-in-practice"
toc: true
permalink: "https://blog.aotoki.me/posts/2023/09/29/rails-in-practice-add-aggregate-entity/"
language: "zh-tw"
---


假設我們繼續確認訂閱的需求，發現現有的功能無法記錄使用者在何時進行延展，因此希望加入「在 2023-03-14 延展」的資訊在畫面上，然而我們現在是使用整數儲存在 `Subscription` 的 `#items` 屬性中，除了無法明確表達意義外，也不容易再繼續擴充。

<!--more-->

## 擴充測試{#extend-test}

為了配合新的需求，這表示我們有新的規格出現，因此在開始實作新的機制之前，先加入新的測試來確認符合規則，因為要設定建立時間，因此我們也加入了一個新的步驟來反應這件事情。

```gherkin
#language:zh-TW
# ...
  場景: Aotoki 有一筆 30 天的訂閱，會有一筆在 2023-01-01 開始的紀錄
    假設 會員 Aotoki 從 2023-01-01 開始有一些訂閱紀錄
      | amount | created_at |
      | 30     | 2023-01-01 |
    當 我打開訂閱狀態頁面
    那麼 我會看到 "在 2023-01-01 延展"
```

根據這個新的步驟，我們還需要加入新的「步驟定義」去描述這件事情。

```ruby
# ...
Given('會員 {word} 從 {word} 開始有一些訂閱紀錄') do |name, date, table|
  user_id = @users[name]

  Subscription.create(user_id: user_id, started_at: Time.zone.parse(date))
  subscription = Subscription.by_user(user_id: user_id).first
  table.hashes.each do |row|
    subscription.extend_with(amount: row['amount'].to_i, created_at: Time.zone.parse(row['created_at']))
  end
  subscription.save!
end
```

這個實作基本上跟 `假設 Aotoki 有兩筆 30 天的訂閱，可以看到 60 天後到期` 這個步驟是差不多的，唯一不同的地方在於我們會將「起始日期」用於 `Subscription` 的起始時間，同時每一筆紀錄都可以額外的設定 `created_at` 的日期。

> 這裡我們利用 `{word}` 來匹配日期，為了更精確的表示，可以用 Cucumber 的 [ParameterType](https://cucumber.io/docs/cucumber/configuration/?lang=ruby) 來登記一個新的參數類型。

## 加入 Subscription Item 實體{#add-subscription-item-entity}

現有的 `Subscription#items` 是一個整數的陣列，這樣就無法去儲存 `created_at` 的資訊，這就是加入新的 Entity（實體）好機會。

```ruby
# app/models/subscription_item.rb

class SubscriptionItem
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :created_at, :datetime, default: -> { Time.zone.now }
  attribute :amount, :integer, default: 0
end
```

有了 `SubscriptionItem` 的 Entity 後，我們要繼續修改 `Subscription` 的 `#extend_with` 方法，相容新的步驟加入的 `created_at` 參數。

```ruby
# app/models/subscription.rb

class Subscription
  # ...
  def extend_with(amount:, created_at: Time.zone.now)
    items << SubscriptionItem.new(amount: amount, created_at: created_at)
  end
end
```

這樣一來我們的資料上就已經調整成可以支援同時保存 `amount` 和 `created_at` 的資訊，然而在呈現的部分就會因為行為改變而無法正確使用，因此我們還需要再去調整呈現的部分。

## 修正顯示{#fix-display}

這次受到影響的部分一共有兩個地方，其中一個是 `SubscriptionHelper` 無法直接用 `items.sum` 的方式。

```ruby
module SubscriptionHelper
  def expired_in_days(subscription:)
    expired_at = subscription.started_at + subscription.items.sum(&:amount).days
    distance = [0, expired_at - Time.zone.now].max
    (distance / 1.day).ceil
  end
end
```

在這裡做的修正需要改為 `items.sum(&:amount)` 的方式，這是利用 Ruby 對陣列操作的語言特性，可以利用 `&:amount` 來表示 `items.sum { |item| item.amount }` 的縮寫，來達到聚合處理的效果。

最後，我們還要讓顯示的部分加入「在 2023-01-01 延展」的顯示，並且修正原本直接印出整數的呈現。

```html
<!-- app/views/subscriptions/index.html.erb -->
<div><%= expired_in_days subscription: @subscription %> 天後到期</div>
<table>
  <thead>
    <tr>
      <th>#</th>
      <th>天數</th>
    </tr>
  </thead>
  <tbody>
    <% @subscription.items.each_with_index do |item, idx| %>
      <tr>
        <td><%= idx %></td>
        <td>在 <%= item.created_at&.to_date %> 延展</td>
        <td>延展 <%= item.amount %> 天</td>
      </tr>
    <% end %>
  </tbody>
</table>
```

到此為止，我們又再一次的通過測試，並且加入了新的功能。

> 因為是舉例的關係在實際開發時，建議再多加入幾個測試來驗證確保功能的完善，來確認是否涵蓋完整的關鍵案例。

