ActiveRecord の attributes API を使って新しい型を作りたい

# やりたいこと

- ActiveRecord の attributes API を使い、 integer/string/date/time などの基本の型をベースに新しい型をつくりたい。
- 記事中の例としては、 Date をベースとした Birthday (誕生日型)をつくり、そこに age (年齢)メソッドを生やす。
- 検証バージョンは Rails 5.2.0

# 背景

```ruby
# users テーブルには birthday という date 型の属性を持っていることを前提としている.
#
class User < ApplicationRecord
  # ユーザは誕生日の属性を持っている。そして、誕生日から導出可能な年齢を知っている。
  def age(today: Date.current)
    return nil unless birthday

    result = today.year - birthday.year
    result -= 1 if today < birthday + result.years
    result
  end
end
```

このコードの問題点として、例えば User の他に Administrator という似たようなモデルがあり、そちらにも誕生日があったとしたときに似た(そのままの)コードが複数箇所に存在することになりつらいということ。

```ruby
# users と同様に、 administrators テーブルには birthday という date 型の属性を持っていることを前提としています.
#
class Administrator < ApplicationRecord
  # 同じことを書いてる。。。。。。。。。
  def age(today: Date.current)
    return nil unless birthday

    result = today.year - birthday.year
    result -= 1 if today < birthday + result.years
    result
  end
end
```

or モジュールで書く？

```ruby:app/models/concerns/age_calculable.rb
module AgeCalculable
  def age(today: Date.current)
    return nil unless birthday

    result = today.year - birthday.year
    result -= 1 if today < birthday + result.years
    result
  end
end

User.include AgeCalculable
Administrator.include AgeCalculable
```

or 誕生日から、年齢を導出するクラス定義を作ってそこに委譲するという手はある。

```ruby
class AgeCalculator
  def initialize(birthday)
    @birthday = birthday
  end

  def age(today: Date.current)
    return nil unless birthday

    result = today.year - birthday.year
    result -= 1 if today < birthday + result.years
    result
  end

  private

  attr_reader :birthday
end
```

```ruby
class User < ApplicationRecord
  delegate :age, to: :age_calculator

  private

  def age_calculator
    AgeCalculator.new(birthday)
  end
end
```

できればこのケースの理想としては **誕生日クラスは年齢を知っている** のが良い(※)と思われる。

# こんなコードで書きたい

`User` モデルの `birthday` 属性は Birthday クラスのインスタンスである。
Birthday クラスは、メソッド `age` をしゃべることができる。

```ruby
user = User.new(birthday: Date.new(2000, 1, 1))

# birthday が age を知っている
user.birthday.age  #=> 19

# 上記前提で age メッセージの応答を birthday に委譲するパターン
class User
  delegate :age, to: :birthday, allow_nil: true
end
user = User.new(birthday: Date.new(2000, 1, 1))
user.age  #=> 19
```

----

# 実装

## Birthday および Birthday::Type

まずは Birthday クラスを定義し、 ActiveRecord レイヤでのシリアライズおよびデシリアライズ方法を有するクラスも定義する。
Date や Integer など、 ActiveRecord の属性にマッピングされたクラスに委譲するクラスを作ると比較的簡単。そうでない場合は [`ActiveModel::Type::Value` クラスの実装](https://github.com/rails/rails/blob/v5.2.2/activemodel/lib/active_model/type/value.rb) を咀嚼し、これを継承して上書きすべきメソッド定義を記述することになる。

```ruby:app/values/birthday.rb
require 'delegate'

class Birthday < DelegateClass(Date)
  # Birthday クラス独自のメソッド記述
  def age(today: Date.current)
    result = today.year - year
    result -= 1 if today < self + result.years
    result
  end

  class Type < ActiveRecord::Type::Date
    def type
      :birthday
    end

    private

    def cast_value(value)
      # 自身の型であるなら dup を返す. そうでない場合は基底クラスに任せたあとその初期化結果をもとに自身のインスタンスを作る.
      case value
      when Birthday then value.dup
      else super.then { |ret| Birthday.new(ret) if ret }
      end
    end
  end
end
```

## ActiveRecord::Type.register により型を登録する

```ruby:config/initializers/active_record.rb
require 'active_record/type'

Rails.application.config.to_prepare do
  ActiveModel::Type.register(:birthday, Birthday::Type)
  ActiveRecord::Type.register(:birthday, Birthday::Type)
end
```

## 使う

```console
# 今回の場合、実際のデータベースの型は基底の date 型で
bundle exec rails g model User birthday:date
```

```ruby:app/models/user.rb
class User < ApplicationRecord
  # attribute :birthday, :date
  attribute :birthday, :birthday  # date 型改め birthday 型

  delegate :age, to: :birthday, allow_nil: true
end
```

## できた

```ruby
user = User.new(birthday: Date.new(2000, 1, 1))
user.age  #=> 19
user = User.new(birthday: '2010-12-31')
user.age  #=> 8
```

# まとめ

- ActiveRecord (ActiveModel) の attributes API を使って独自の型の属性を作成できる
- 既存の型を拡張した型を作る場合は既存の型への委譲を前提とすると簡単

----

※ フレームワークに密に依存しないほうが良いという観点があります。が、ここではとりあえずそういうことにしておいてください :bow: