ActiveRecordモデルの拡張検討

ActiveRecordモデルの拡張方法にはいくつか選択肢があるので、どういった方法を選ぶのが適切かよくよく考えなおしてみたいと思います

 
## 今回のストーリー
新規ユーザーは必ず無料のサンプルを受け取ることができるので、ユーザーが作成されたときに、無料サンプル用の注文を作成したい
ただし、受け取れる無料サンプルは一人一つまでで、無料サンプルを提供する販売元と無料サンプルはコードで指定したい

販売元コード: DEFAULT
商品コード: JOIN

![スクリーンショット 2022-09-17 11.01.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89997/0445fa77-d120-534f-a95b-36a9d576133a.png)

## 2種類の拡張方法を試す
1. [scopeを使った拡張を使った拡張](#1scopeを使った拡張)
2. [AssociationExtentionを使ったhas_manyリレーションの拡張](#2associationextentionを使ったhas_manyリレーションの拡張)

## 1.Scopeを使った拡張
Scopeを使った拡張ではクラス本体が拡張される
そのクラスを参照する全ての場合に利用できる
buildやcreateにも利用することが可能
[ActiveRecord::Scoping::Named::ClassMethods](https://api.rubyonrails.org/v7.0.3.1/classes/ActiveRecord/Scoping/Named/ClassMethods.html#method-i-scope)

まずは特定条件でレコードがフィルタリングできることを確認します

:memo: 実装
```ruby
class Order < ApplicationRecord
  belongs_to :customer
  belongs_to :item
  # 発送前の注文をフィルタするスコープを定義
  scope :not_yet_shipped, -> { where("shipment_date < ?", Time.zone.now)}
end
```

:computer: 検証
下記の2つの呼び出し方法で同じSQLが発行されていることを確認します
```ruby 
> Order.not_yet_shipped
Order Load (0.5ms)  SELECT `orders`.* FROM `orders` WHERE (shipment_date < '2022-09-17 01:23:32.786304')

> Customer.last.orders.not_yet_shipped
Order Load (0.5ms)  SELECT `orders`.* FROM `orders` WHERE (shipment_date < '2022-09-17 01:23:32.786304')
```

次に、スコープからレコードが作成できることも確認します
ただし、この場合には、「無料のサンプルは一人ひとつまで」という制約を入れたい場合に、scopeの中だけでは拡張できないので、`create_join_bonus`をメソッドとして定義することになります

:memo: 実装

```ruby
class Order < ApplicationRecord
  belongs_to :customer
  belongs_to :item
  # キャンペーンの注文をフィルタするスコープの定義
  scope :campaign_order, -> {
    where(item: Item.where({seller: Seller.where(code: 'DEFAULT').first, campaign_code: 'JOIN'}).last)
  }

 # 無料サンプル用のレコードを作成
  def create_join_bonus
    find_or_create_by!(item: find_join_bonus) do |order|
      order.shipment_date = Time.zone.now + 3.days
    end
  end

 # 無料サンプル用のレコードを検索
  def find_join_bonus
    seller = Seller.where(code: 'DEFAULT').first
    Item.where({seller: seller, campaign_code: 'JOIN'}).last
  end
end
```

:computer: 検証
campaign_orderのスコープからだと何件も同じレコードを作成できてしまう抜け道があることを確認する

```ruby
> Customer.last.orders.campaign_order.create
=> #<Order:0x00007fc84e190c30
 id: 3,
 customer_id: 3,
 item_id: 1,
 shipment_date: nil,
 created_at: Sat, 17 Sep 2022 01:07:54 UTC +00:00,
 updated_at: Sat, 17 Sep 2022 01:07:54 UTC +00:00>

> Customer.last.orders.campaign_order
=> [#<Order:0x00007fc84e23bf68
  id: 2,
  customer_id: 3,
  item_id: 1,
  shipment_date: nil,
  created_at: Sat, 17 Sep 2022 01:07:52 UTC +00:00,
  updated_at: Sat, 17 Sep 2022 01:07:52 UTC +00:00>,
 #<Order:0x00007fc84e23be00
  id: 3,
  customer_id: 3,
  item_id: 1,
  shipment_date: nil,
  created_at: Sat, 17 Sep 2022 01:07:54 UTC +00:00,
  updated_at: Sat, 17 Sep 2022 01:07:54 UTC +00:00>]
```

## 2.AssociationExtentionを使ったhas_manyリレーションの拡張

[Active Record の関連付け - Railsガイド](https://railsguides.jp/association_basics.html)

Orderモデルは一般的なECサイトなどでは、必然的に非常にたくさんの機能を抱えることになります

「無料のサンプルは一人ひとつまで」という業務要件は、Customerモデルの1行(=一人)に対して、紐づく独自要件なのでキャンペーンの要件としてひとくくりにしてて、モジュールに切り出したいと思います

そこで、has_manyリレーション内で、新たに作成された`CampaignExtension` を使って拡張したいと思います

:::note info
この場合には、クラス本体は拡張されず、orders経由でレコードが作成される場合にのみ拡張されます
:::

:::note warn
has_oneやbelongs_toのときには利用できないので注意しましょう
:::
:memo: 実装
```ruby
class Customer < ApplicationRecord
  has_many :orders, -> { extending CampaignExtension },
           class_name: 'Order', inverse_of: :customer
end
```

`app/models/concerns/campaign_extension.rb` を新たに作成します
create_join_bonusというメソッドに「無料のサンプルは一人ひとつまで」という制限をまとめて実装してしまいましょう

```ruby
module CampaignExtension
  def create_join_bonus
    find_or_create_by!(item: find_join_bonus) do |order|
      order.shipment_date = Time.zone.now + 3.days
    end
  end

  def find_join_bonus
    seller = Seller.where(code: 'DEFAULT').first
    Item.where({seller: seller, campaign_code: 'JOIN'}).last
  end
end
```

`order`モデルからは、ロジックを削除して、このようにシンプルな形に戻しましょう

```ruby
class Order < ApplicationRecord
  belongs_to :customer
  belongs_to :item
  # 発送前の注文をフィルタするスコープを定義
  scope :not_yet_shipped, -> { where("shipment_date < ?", Time.zone.now)}
```

:computer: 検証検証
何度呼び出しても重複レコードが作成されないことを確認しましょう
```ruby
> Customer.last.orders.create_join_bonus
=> #<Order:0x00007fd528524568
 id: 1,
 customer_id: 2,
 item_id: 1,
 shipment_date: Sun, 18 Sep 2022 15:09:10 UTC +00:00,
 created_at: Thu, 15 Sep 2022 15:09:10 UTC +00:00,
 updated_at: Thu, 15 Sep 2022 15:09:10 UTC +00:00>

>  Customer.last.orders
=> [#<Order:0x00007fc84e23bf68
  id: 2,
  customer_id: 3,
  item_id: 1,
  shipment_date: nil,
  created_at: Sat, 17 Sep 2022 01:07:52 UTC +00:00,
  updated_at: Sat, 17 Sep 2022 01:07:52 UTC +00:00>,
 #<Order:0x00007fc84e23be00
  id: 3,
  customer_id: 3,
  item_id: 1,
  shipment_date: nil,
  created_at: Sat, 17 Sep 2022 01:07:54 UTC +00:00,
  updated_at: Sat, 17 Sep 2022 01:07:54 UTC +00:00>]
```


:::note info
「無料のサンプルは一人ひとつまで」という制限がActiveRecordの機能を通して実現できたというよりは、制限を置く場所を変えてあげると、あとからメソッドを利用する人が間違えにくくなるという、認識を持っていただけれと思います
また、ドメインとモデルの分離をしてFat Modelをダイエットさせるというときにも役に立つと思います
:::