1.year
や 2.weeks
などで生成される ActiveSupport::Duration をデータベースに保存してロードできるようにしたい。
どうシリアライズする?
ActiveSupport::Duration をそのまま DB に保存はできないので、シリアライズの方法を考える必要がある。
秒数
ActiveSupport::Duration には to_i
メソッドがあり、秒数を返す。これは必ずしも実際の秒数ではない (たとえば月によって日数がかわるため) が、ActiveSupport::Duration.build
に渡せばだいたい復元することができる。
値をみてもどれくらいの期間なのかはわかりにくいが、たんなる整数なので、DB に不正な値が入る危険が少なく、比較やソートもしやすい。
しかし、たとえば 1.month.to_i
は 2629746
と定義されており 2629746.seconds.to_i
と区別できない。ActiveSupport::Duration.build(2629746)
は 1.month
と解釈されるので、だいたいの場合は問題ないだろうが、やや厳密さにかける。
※ 年の秒数はグレゴリオ年の秒数、月の秒数はその 1/12 と定義されている
ISO8601
もう一つの方法として、iso8601
メソッドで得られる ISO8601 の継続時間を表す文字列が使える。たとえば 2.years.iso8601
は P2Y
、10.minutes.iso8601
は PT10M
のようになる。これは ActiveSupport::Duration.parse
で復元できる。
読み方さえわかれば秒数よりも理解しやすいが、比較やソートは難しい。
この方法だと 1.month.iso8601
は P1M
、2629746.seconds.iso8601
は PT2629746S
のように区別され、ActiveSupport::Duration.parse
に渡すとそれぞれもとの形に復元される。
※ただし 0.year.iso8601
0.day.iso8601
0.hour.iso8601
などは区別されず PT0S
になるようだ
なお、ISO8601 には 14.days
をP0000-00-14T00:00:00
のように表現する形式もあるが、この形式では 30 時間
のような値を表現できないため、これ以上検討しない。
ActiveRecord::Type::Value
上記の ISO8601 による文字列表現を使って、ActiveRecord::Attributes::ClassMethods#attribute で使える型を定義する。
# DB での型に対応するクラスを継承
class DurationType < ActiveRecord::Type::String
# attribute_name= で assign された値の変換
def cast(value)
case value
when ActiveSupport::Duration
value
when NilClass
nil
else
begin
ActiveSupport::Duration.parse(value.to_s)
rescue ActiveSupport::Duration::ISO8601Parser::ParsingError
nil
end
end
end
# DB から読んだ値の変換
def deserialize(value)
return nil if value.nil?
ActiveSupport::Duration.parse(value)
end
# DB に書き込む値
def serialize(value)
return nil if value.nil?
value.iso8601
end
end
ActiveRecord::Type.register(:duration, DurationType)
# attribute :attribute_name, :duration
これで下記のように使える。
class Plan < ActiveRecord::Base
attribute :term, :duration
end
Plan.create!(term: 2.years).reload.term == 2.years # true
Plan.create!(term: "PT3H").reload.term == 3.hours # true