やりたいこと
- ActiveRecord の attributes API を使い、 integer/string/date/time などの基本の型をベースに新しい型をつくりたい。
- 記事中の例としては、 Date をベースとした Birthday (誕生日型)をつくり、そこに age (年齢)メソッドを生やす。
- 検証バージョンは Rails 5.2.0
背景
# 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 という似たようなモデルがあり、そちらにも誕生日があったとしたときに似た(そのままの)コードが複数箇所に存在することになりつらいということ。
# 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 モジュールで書く?
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 誕生日から、年齢を導出するクラス定義を作ってそこに委譲するという手はある。
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
class User < ApplicationRecord
delegate :age, to: :age_calculator
private
def age_calculator
AgeCalculator.new(birthday)
end
end
できればこのケースの理想としては 誕生日クラスは年齢を知っている のが良い(※)と思われる。
こんなコードで書きたい
User
モデルの birthday
属性は Birthday クラスのインスタンスである。
Birthday クラスは、メソッド age
をしゃべることができる。
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
クラスの実装 を咀嚼し、これを継承して上書きすべきメソッド定義を記述することになる。
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 により型を登録する
require 'active_record/type'
Rails.application.config.to_prepare do
ActiveModel::Type.register(:birthday, Birthday::Type)
ActiveRecord::Type.register(:birthday, Birthday::Type)
end
使う
# 今回の場合、実際のデータベースの型は基底の date 型で
bundle exec rails g model User birthday:date
class User < ApplicationRecord
# attribute :birthday, :date
attribute :birthday, :birthday # date 型改め birthday 型
delegate :age, to: :birthday, allow_nil: true
end
できた
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 を使って独自の型の属性を作成できる
- 既存の型を拡張した型を作る場合は既存の型への委譲を前提とすると簡単
※ フレームワークに密に依存しないほうが良いという観点があります。が、ここではとりあえずそういうことにしておいてください