ActiveRecord::Base の setter メソッドの謎

仕事仲間から ActiveRecord::Base#[:属性名]= と ActiveRecord::Base#(属性名)= の違いをたずねられた。
そこでちょっとしらべてみた。

たとえば Entry という AR クラスがあるとして、そこに title という属性があるとしよう。

entry = Entry.find(:first)
entry.title   # => "Hello World!"
entry[:title] # => "Hello World!"
entry.title   = "こんにちは"
v[:title] = "おはよう"

のように entry[:title] と entry.title の両方を使うことができるのだ。

ActiveRecord::Base#, ActiveRecord::Base#= の定義は以下のとおり。

def [](attr_name)
  read_attribute(attr_name)
end

def []=(attr_name, value)
  write_attribute(attr_name, value)
end

これは素直な実装だ。read_attribute, write_attribute の詳細には立ち入らないが、AR オブジェクトのカラムごとの値が格納されている @attributes というハッシュを参照・更新している。

では、ActiveRecord::Base#(属性名) はどうであろう?これは ActiveRecord::Base#method_missing を見るとわかる。

def method_missing(method_id, *args, &block)
  method_name = method_id.to_s
  if @attributes.include?(method_name) or  # (A)
      (md = /\?$/.match(method_name) and
      @attributes.include?(query_method_name = md.pre_match) and
      method_name = query_method_name)
    define_read_methods if self.class.read_methods.empty? && self.class.generate_read_methods
    md ? query_attribute(method_name) : read_attribute(method_name)
  elsif self.class.primary_key.to_s == method_name
    id
  elsif md = self.class.match_attribute_method?(method_name) #(B)
    attribute_name, method_type = md.pre_match, md.to_s
    if @attributes.include?(attribute_name)
      __send__("attribute#{method_type}", attribute_name, *args, &block)
    else
      superd
    end
  else
    super
  end
end

たとえば、entry.title の場合、まず Entry クラスには title メソッドは定義されていないので、上の method_missing が呼び出される。すると (A) の条件に一致して、内部が実行される。具体的には、define_read_methods が呼び出され、ここで Entry#title 等、すべての属性の getter メソッドが定義される。

さて最後までわからなかったのが Entry#title= 等の setter メソッドである。どうやら、これも method_missing で処理されているらしいのだが、具体的にどこで処理されているのかわからない。いろいろ調べてみると、驚くべきことに上の (B) で処理されていることがわかった。

attribute methods というテクニックを使っているのだ。AR クラス関係のファイルのひとつに attribute_methods.rb があるので中身を見てほしい。全文を掲載したが、短くするためにコメントを省略した。

module ActiveRecord
  module AttributeMethods
    DEFAULT_SUFFIXES = %w(= ? _before_type_cast)

    def self.included(base)
      base.extend ClassMethods
      base.attribute_method_suffix *DEFAULT_SUFFIXES
    end

    module ClassMethods
      def attribute_method_suffix(*suffixes)
        attribute_method_suffixes.concat suffixes
        rebuild_attribute_method_regexp
      end

      def match_attribute_method?(method_name)
        rebuild_attribute_method_regexp unless defined?(@@attribute_method_regexp) && @@attribute_method_regexp
        @@attribute_method_regexp.match(method_name)
      end

      private
        def rebuild_attribute_method_regexp
          suffixes = attribute_method_suffixes.map { |s| Regexp.escape(s) }
          @@attribute_method_regexp = /(#{suffixes.join('|')})$/.freeze
        end

        def attribute_method_suffixes
          @@attribute_method_suffixes ||= []
        end
    end

    private
      def attribute?(attribute_name)
        query_attribute(attribute_name)
      end

      # Handle *= for method_missing.
      def attribute=(attribute_name, value)
        write_attribute(attribute_name, value)
      end

      def attribute_before_type_cast(attribute_name)
        read_attribute_before_type_cast(attribute_name)
      end
  end
end

module ClassMethods 以下のメソッドは、おなじみのテクニックで ActiveRecord::Base に mix-in される。つまり ActiveRecord::Base のインスタンスメソッドとして振る舞うということである。attribute methods はどう使うかというと、以下の例のように使う。(ソースコードコメントより)

class Person < ActiveRecord::Base
  attribute_method_suffix '_changed?'

  private
  def attribute_changed?(attr)
  ...
  end
end

person = Person.find(1)  
person.name_changed?    # => false
person.name = 'Hubert'
person.name_changed?    # => true

上の例では _changed? という接尾辞を定義している。すると name_changed? が自動的に使えるようになる。仮に address という属性があれば address_changed? も自動的に使える。いずれにしろ実体は attribute_changed? であり、 name_changed? でも address_changed? でも同じメソッドが呼び出されることになる。

さて、ActiveRecord の実例をみると、DEFAULT_SUFFIXES = %w(= ? _before_type_cast) と定義されていることからわかるように、基本的に =, ?, _before_type_cast という3種類の接尾辞があらかじめ定義されているのだ。
つまりこれで、Entry#title= が使えることがわかった。Entry#title= の実体は上の attribute= である。この内部処理は、[]= と同じく write_attribute の呼び出しである。代入のために特別な処理を書かず、attribute methods という汎用的なテクニックで処理しているあたり、さすがは Rails とうなってしまった。