Engineering from Scratch

エンジニア目指してます

ActiveModel::APIを調べてみた

ActiveModel::Model

api.rubyonrails.org

ActiveRecord::Baseと同じように,クラスをModelのように扱えるようにするmodule。

module ActiveModel
  module Model
    extend ActiveSupport::Concern
    include ActiveModel::API
  end
end

実装は,extendとincludeを行っているだけだった。ActiveModel::APIの実装を見てみる。

ActiveModel::API

以下のModuleをincludeしている。

  • ActiveModel::AttributeAssignment
  • ActiveModel::Validations
  • ActiveModel::Conversion

ActiveModel::APIでやっていることは,includeされたクラスのattributeを引数にinitializeできるようになる。以下のようにクラスを定義する。

class Human
  include ActiveModel::API

  attr_writer :first_name
end

すると,以下のようにinitializeできる。

irb(main):208:0> human = Human.new(first_name: 'Taro')
=> #<Human:0x00007fa0072ca098 @first_name="Taro">

setterメソッドが定義されていれば,initializeする引数に追加できるようになる。ActiveRecordでも同様に,initializeの引数にattributeが追加できるが,この実装がどうなっているのか少し気になったので読んでみる。

/Users/yoshiokayuya/.rbenv/versions/3.0.2/lib/ruby/gems/3.0.0/gems/activemodel-7.0.2.2/lib/active_model/api.rb

    def initialize(attributes = {})
      assign_attributes(attributes) if attributes

      super()
    end

assign_attributesの実装を見てみる。

    def assign_attributes(new_attributes)
      unless new_attributes.respond_to?(:each_pair)
        raise ArgumentError, "When assigning attributes, you must pass a hash as an argument, #{new_attributes.class} passed."
      end
      return if new_attributes.empty?

      _assign_attributes(sanitize_for_mass_assignment(new_attributes))
    end

initializeで渡された引数がeach_pairを使える,StructやHash以外であれば例外,空であればメソッドが終了する。そして,渡された引数に対して,以下のsanitize_for_mass_assignmentが呼ばれている。

/Users/yoshiokayuya/.rbenv/versions/3.0.2/lib/ruby/gems/3.0.0/gems/activemodel-7.0.2.2/lib/active_model/forbidden_attributes_protection.rb

      def sanitize_for_mass_assignment(attributes)
        if attributes.respond_to?(:permitted?)
          raise ActiveModel::ForbiddenAttributesError if !attributes.permitted?
          attributes.to_h
        else
          attributes
        end
      end

今回は,ActionController::Parametersインスタンスではないため,attributesをそのまま引数として,_assign_attributesが呼び出される。

      def _assign_attributes(attributes)
        attributes.each do |k, v|
          _assign_attribute(k, v)
        end
      end

      def _assign_attribute(k, v)
        setter = :"#{k}="
        if respond_to?(setter)
          public_send(setter, v)
        else
          raise UnknownAttributeError.new(self, k.to_s)
        end
      end

あとは,sendを使って各attributesに対してsetterメソッドを呼ぶだけ。だから,attr_writerが定義されているものさえあればinitializeの引数に加えることができる。また,setterが定義されていないものをinitialize時の引数に加えると

irb(main):213:0> human = Human.new(first_nam: 'Taro')
/Users/yoshiokayuya/.rbenv/versions/3.0.2/lib/ruby/gems/3.0.0/gems/activemodel-7.0.2.2/lib/active_model/attribute_assignment.rb:51:in `_assign_attribute': unknown attribute 'first_nam' for Human. (ActiveModel::UnknownAttributeError)

というエラーをよく見かけるが,それは

          raise UnknownAttributeError.new(self, k.to_s)

に起因するものだった。

UnknownAttributeError

話は逸れるが,UnknownAttributeErrorclassを読んでみた。

  class UnknownAttributeError < NoMethodError
    attr_reader :record, :attribute

    def initialize(record, attribute)
      @record = record
      @attribute = attribute
      super("unknown attribute '#{attribute}' for #{@record.class}.")
    end
  end

initialize時の引数にrecord,attributeをとる。先ほどのHumanクラスの例では,recordHumanattributehogeが当てはまる。そして,superで,親のNoMethodErrorクラスのinitialize時に,"unknown attribute '#{attribute}' for #{@record.class}."というmsgが渡される。