🐫

ActiveModel::Attributes で定義した属性への `?` 付き呼び出しを実装する

に公開

ActiveModel でも ? サフィックス付きの属性メソッド呼び出しに応答してほしい

例えば ActiveRecord のモデルだと、定義された属性に対して object.fooobject.foo = 1 のような更新や参照が可能なわけですが、更に object.foo? といった形のメソッドも定義されています。

# 以下のテーブルが存在するとして…
#     create_table :models do |t|
#       t.foo :string
#       t.bar :boolean
#     end
#
class Model < ActiveRecord::Base
end

model = Model
model.foo = "HOGE"
model.bar = true

model.foo   #=> "HOGE"
model.bar?  #=> true

# boolean に限った定義というわけでなく、全属性で ? できる
model.foo?  #=> true

一方、 ActiveModel の場合だと ActiveModel::Attributes を使って属性を定義するわけですが ? サフィックス付きのメソッドは定義されません。

class User
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :age, :integer
  attribute :active, :boolean
end
user = User.new(name: "foo", age, 18, active: true)

user.name
=> "foo"
user.age
=> 18
user.active
=> true

user.active?  #=> undefined method 'active?'

もちろん、自分で定義すれば使えるわけですが、このトピックは属性が増える都度これを書くという話ではありません。
これを ActiveModel でもいい感じに実装することについて考えます。

class User
  # 省略...

  def active? = active.present?
end

user = User.new(name: "foo", age, 18, active: true)
user.active?  #=> true

ActiveRecord ではどうなっているか?

ActiveRecord の場合、 ActiveRecord::AttributeMethods::Query にその実装があります。

そこでのポイントは、以下です。

        attribute_method_suffix "?", parameters: false

attribute_method_suffixActiveModel::AttributeMethods で提供される定義です。
attriibuteなんちゃら みたいな名前のメソッドを定義することができるものであり、これの仲間に attribute_method_prefix (前置詞) や attribute_method_affix (接辞. つまり prefix と suffix を同時に) があります。

class Foo
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute_method_suffix "_double", parameters: false

  attribute :bar, :string
  attribute :baz, :integer

  private

  def attribute_double(attr_name)
    value = attribute(attr_name)
    value * 2
  end
end

foo = Foo.new(bar: "HOGE", baz: 10)

foo.bar         #=> "HOGE"
foo.bar_double  #=> "HOGEHOGE"
foo.baz_double  #=> 20

ActiveModel 版の AttributeMethods::Query を実装してみる

というわけで ActiveRecord::AttributeMethods::Query にならって、 ActiveModel で動作する実装を作ってみます。
以下みたいな形になります。

module QueryableAttribute
  extend ActiveSupport::Concern

  included do
    include ActiveModel::Attributes
    attribute_method_suffix "?", parameters: false
  end

  private

  def attribute?(attr_name)
    value = attribute(attr_name)

    if value.respond_to?(:zero?)
      !value.zero?
    else
      value.present?
    end
  end
end

この、QueryableAttribute モジュールを include します。

class Foo
  include ActiveModel::Model
  include ActiveModel::Attributes
  include QueryableAttribute

  attribute :bar, :string
  attribute :baz, :integer
end

foo = Foo.new(bar: "HOGE", baz: 10)

foo.bar         #=> "HOGE"
foo.bar?        #=> true
foo.bar = ""
foo.bar?        #=> false

これで、このモジュールによるサフィックスに ? がついた実装を定義できました。

全部 present? ではダメ?

以下は少しだけ詳細につっこんでいる内容です。
あまり興味がない場合は、「ActiveRecord での 属性名+? のメソッドは、値がゼロのときに偽を返すようになっている」とだけで OK です。

# ActiveRecord での 属性名+? のメソッドは、値がゼロのときに false を返します
model = Model.first

model.id    #=> 1
model.id?   #=> true

model.id = 0
model.id    #=> 0
model.id?   #=> false

で、先の attribute? の実装ですが

  def attribute?(attr_name)
    attribute(attr_name).present?
  end

とはしていません。
これは、挙動を ActiveRecord::AttributeMethods::Query に寄せているためです。

ActiveRecord::AttributeMethods::Query での判定は以下の query_cast_attribute が大部分を担っています。

https://github.com/rails/rails/blob/v8.0.2/activerecord/lib/active_record/attribute_methods/query.rb#L29-L47

true, false, nil の special case については early return していますね。
結果としては同じになるので自前で実装する場合はお好みで組み入れるも良いと思います。

          case value
          when true        then true
          when false, nil  then false

以下の条件の箇所は少々ややこしいので端折っています。

            if !type_for_attribute(attr_name) { false }
              # ここ

(db:migrate を実施したときとか起因?) 実際の DB スキーマと、 ActiveRecord モデル自身が把握しているスキーマに乖離がある場合のフォローみたいなもののようです。
ActiveModel でこの条件を加味しないと具体的にどうなるか? ですが、以下のように attribute の第2引数に型を指定しない場合などに微妙な挙動の違いが生まれます。
ただ、そこまで厳密に ActiveRecord と合わせきる必要がある? ⇒ なさそうだ。と考えて、これで良いとしています。 [1]

class Hoge
  include ActiveModel::Model
  include ActiveModel::Attributes
  include QueryableAttribute

  attribute :foo
  attribute :bar, :string
  attribute :baz, :integer
  attribute :qux, :boolean
end
hoge = Hoge.new
hoge.foo = "0"
hoge.bar = "0"
hoge.baz = "0"
hoge.qux = "0"
[hoge.foo, hoge.foo?]  #=> ["0", true] <<< ここは、件の条件を加味するならば false ということに
[hoge.bar, hoge.bar?]  #=> ["0", true]
[hoge.baz, hoge.baz?]  #=> [0, false]
[hoge.qux, hoge.qux?]  #=> [false, false]

残りの条件が、以下です。

            elsif value.respond_to?(:zero?)
              !value.zero?
            else
              !value.blank?
            end

おおよそ以下の挙動ということになります。

  • #zero? に応答可能なオブジェクト (主に Integer などの数値)
    • value.zero? の評価結果の反対
  • そうでない場合
    • value.blank? の評価結果の反対 (つまり value.present? と同義)

値がゼロであるならば ? 付き呼び出しは false を返すということになります。

u1 = User.new(name: "bar", age: 0, active: false)
u1.age?
=> false

まとめ

  • ActiveRecord と違って ActiveModel の attribute では ? サフィックス付きの呼び出しには応答しません
  • attribute_method_suffix を使うと、それを実装する機能が実現できました
  • ActiveRecord での ? サフィックス付き呼び出しは、実は値がゼロのときに false を返します
脚注
  1. 個人的にはこのあたりの複雑さをなくすために、 zero? への応答は加味せずに全てのケースで単に #present? で問う形のほうがよいのではないだろうかと思っています ↩︎

Discussion