【Rails】「ActiveModel::Attributes」が便利という話

f:id:Ushinji:20190617215708p:plain

はじめに

Rails 6.0が今年リリースされようかという今になって、Rails 5.2の新機能であるActiveModel::Attributesの便利さを知りました。

なので、今回は 「ActiveModel::Attributes」について、学んだことをアウトプットしたいと思います。

ActiveRecordの「Attribute API

今回の話のそもそもの前提として、RailsActiveRecordにあるAttribute APIという機能を知っておくと、話がわかりやすいと思います。

このAttribute APIがあるおかげで、ActiveRecordを操作する際にクラス属性の型を意識しなくても、指定の型へ変換してくれています。

# Userの属性の型定義
# - is_admin: boolean
# - age: integer
# - birthday: date

user = User.new({
  is_admin: "true", 
  age: "20", 
  birthday: "2019-06-12"
})

puts user.is_admin
# => true (boolean型に変換)

puts user.age
# => 20 (integer型に変換)

puts user.birthday
# => Wed, 12 Jun 2019 (date型に変換)

あまりに自然に型変換が行われているため、Attribute APIを意識している人は少ないのではないでしょうか?

ActiveModelでも「Attribute API」を利用できる

上述の「Attribute API」機能について、ActiveRecordだけでなくActiveModelでも利用することができます。

例として、Todoリストの作成するフォームを考えてみましょう。 Todoに入力する内容は、以下のとします。

  • content:Todoの内容(string)
  • limit_date:Todoの期限(date)

このTodoFormクラスをAttribute APIを利用した実装をすると、以下のようになります。

# frozen_string_literal: true

class TodoForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :text, :string
  attribute :limit_date, :date

  validates :text, presence: true
  validates :limit_date, presence: true

end

Attribute APIを利用するには、ActiveModel::Modelに加えて、ActiveModel::Attributesをインクルードするだけです。

そしてattributeを使い、各属性名とその型情報を指定するだけで完成です。

以下が動作ですが、文字列で渡した日付データがDate型に変換されていることがわかります。

# 正常系
form = TodoForm.new(text: "TEST_TEXT", limit_date: "2019-06-12")

form.limit_date
# => Wed, 12 Jun 2019

次に不正な形式の日付データを渡した場合は、nilが設定されます。そのため、presenceバリデーションを指定するだけで、日付の形式チェックも同時に行うことができます。非常に便利ですね。

# 異常系:不正な形式の日付データ
form = TodoForm.new(text: "TEST_TEXT", limit_date: "2019-13-32")

form.limit_date
# => nil

form.valid?
=> false

デフォルトで使用できる型

デフォルトで使用できる型は以下のソースコードに記載されています。

github.com

:big_integer, Type::BigInteger
:binary, Type::Binary
:boolean, Type::Boolean
:date, Type::Date
:datetime, Type::DateTime
:decimal, Type::Decimal
:float, Type::Float
:immutable_string, Type::ImmutableString
:integer, Type::Integer
:string, Type::String
:time, Type::Time

datetime型を使う場合のタイムゾーンの扱い

Attribute APIを使用する際に、注意が必要なのが1つあり、それはdatetime型の属性を扱う場合です。

先ほどのlimit_dateをdatetime型に変換してみます。

# frozen_string_literal: true

class TodoForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :text, :string
  attribute :limit_date, :datetime

  validates :text, presence: true
  validates :limit_date, presence: true

end

limit_dateにdatetime型のデータの文字列を指定すると、以下のようにタイムゾーンが意図した値にならないことがわかります。

# 日付時刻の文字列を渡した場合
form = TodoForm.new(text: "TEST_TEXT", limit_date: "2019-06-17 21:33:32 +0900")

# タイムゾーンが保存されない
form.limit_date
# => 2019-06-17 12:33:32 +0000

こちらについて解説された以下の記事によるとActiveModel::Attributesのdatetime型で型変換が行われる場合、タイムゾーンの指定は「システムに設定されているタイムゾーン」または「UTC」になるそうです。

qiita.com

そのため、datetime型の属性を指定する際は、タイムゾーンを指定したdatetime型を指定するのが意図しないバグを生まないために大事だと思います。

limit_date = Time.new(2019, 06, 12, 13).in_time_zone('Asia/Tokyo')
# => Wed, 12 Jun 2019 22:00:00 JST +09:00

form = TodoForm.new(text: "TEST_TEXT", limit_date: limit_date)

form.limit_date
# => Wed, 12 Jun 2019 22:00:00 JST +09:00

終わりに

ActiveModel::Attributesは非常にシンプルに、属性の型の指定と変換を行ってくれるのが、とても便利ですね!

これまではこのような型指定、変換をする場合、 VirtusというGemがありましたが、ActiveModel::Attributesに切り替えていこうと思いました。

参考文献