@ledsun blog

無味の味は佳境に入らざればすなわち知れず

ActiveRecordのソースコードを読む

Ruby on Railsを使ったアプリケーション開発で、DBにMySQLを使いカラムの型定義がIntegerの時にデフォルト値が文字列で設定される現象を観測しました。 デフォルト値だけ見て型をみないことに疑問を感じました。 そこでActiveRecordソースコードを読みました。

準備

規模の大きなRubyソースコードを読むときは、手元にソースコードを持ってきます。

git clone git@github.com:rails/rails.git

次のメリットがあります。

  1. RubyMineを使って定義を探せる
  2. 実際に動かせる
  3. 修正して動きを変更できる

入り口を探す

ActiveRecordはかなり大きなモジュールです。どこから読んだらいいのか目星をつけたいです。 今回はオブジェクトの初期化に関するソースコードが読みたいです。 というわけで、適当なクラスを初期化します。

~ bundle exec irb -r active_record
irb(main):001:0> class Post < ActiveRecord::Base; end
=> nil
irb(main):002:0> Post.new
/Users/shigerunakajima/rails/activerecord/lib/active_record/connection_adapters/abstract/connection_handler.rb:208:in `retrieve_connection': No connection pool for 'ActiveRecord::Base' found. (ActiveRecord::ConnectionNotEstablished)
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/connection_handling.rb:309:in `retrieve_connection'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/connection_handling.rb:265:in `connection'
    from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:554:in `load_schema!'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/attributes.rb:264:in `load_schema!'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:540:in `block in load_schema'
    from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:537:in `synchronize'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:537:in `load_schema'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:403:in `attribute_types'
    from /Users/shigerunakajima/rails/activerecord/lib/active_record/attribute_methods.rb:187:in `_has_attribute?'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/inheritance.rb:59:in `new'
  from (irb):2:in `<main>'
    from /Users/shigerunakajima/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/irb-1.3.5/exe/irb:11:in `<top (required)>'
  from /Users/shigerunakajima/.rbenv/versions/3.0.1/bin/irb:23:in `load'
  from /Users/shigerunakajima/.rbenv/versions/3.0.1/bin/irb:23:in `<top (required)>'
    from /Users/shigerunakajima/.rbenv/versions/3.0.1/lib/ruby/3.0.0/bundler/cli/exec.rb:63:in `load'
  from /Users/shigerunakajima/.rbenv/versions/3.0.1/lib/ruby/3.0.0/bundler/cli/exec.rb:63:in `kernel_load'
  ... 13 levels...

activerecord/lib/active_record/attribute_methods.rb:187:in `_has_attribute?'

が気になります。

https://github.com/rails/rails/blob/94b954576a0bb34ca81378b8a200d5d794a0a997/activerecord/lib/active_record/attribute_methods.rb#L186-L188

      def _has_attribute?(attr_name) # :nodoc:
        attribute_types.key?(attr_name)
      end

いまいちでした。 呼び出し元をみます。

https://github.com/rails/rails/blob/94b954576a0bb34ca81378b8a200d5d794a0a997/activerecord/lib/active_record/inheritance.rb#L54-L76

      def new(attributes = nil, &block)
        if abstract_class? || self == Base
          raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated."
        end

        if _has_attribute?(inheritance_column)
          subclass = subclass_from_attributes(attributes)

          if subclass.nil? && scope_attributes = current_scope&.scope_for_create
            subclass = subclass_from_attributes(scope_attributes)
          end

          if subclass.nil? && base_class?
            subclass = subclass_from_attributes(column_defaults)
          end
        end

        if subclass && subclass != self
          subclass.new(attributes, &block)
        else
          super
        end
      end

newメソッドが定義されています。 いかにも入り口という感じです。

newメソッドを読む

newメソッドの中身をみるとsubclassをさがしています。 subclassはなんでしょうか? subclassを取得するのに使っているsubclass_from_attributesメソッドを覗いてみます。

https://github.com/rails/rails/blob/94b954576a0bb34ca81378b8a200d5d794a0a997/activerecord/lib/active_record/inheritance.rb#L314-L323

        def subclass_from_attributes(attrs)
          attrs = attrs.to_h if attrs.respond_to?(:permitted?)
          if attrs.is_a?(Hash)
            subclass_name = attrs[inheritance_column] || attrs[inheritance_column.to_sym]

            if subclass_name.present?
              find_sti_class(subclass_name)
            end
          end
        end

find_sti_classが気になります。 おそらくSTIを使っている場合にインスタンス化するクラスを変更する処理でしょう。

そう仮定して、newメソッドを見直すと、subclassが見つからなかったときはsuperメソッドを読んでいます。

superは何か?

ソースコードから、自信を持ってsuperが何かを読み取るのは難しいです。 newメソッドをいきなりsuperを呼ぶように変更します。

      def new(attributes = nil, &block)
          super
      end

もう一回初期化してみましょう。

~ bundle exec irb -r active_record
irb(main):001:0> class Post < ActiveRecord::Base; end
=> nil
irb(main):002:0> Post.new
/Users/shigerunakajima/rails/activerecord/lib/active_record/connection_adapters/abstract/connection_handler.rb:208:in `retrieve_connection': No connection pool for 'ActiveRecord::Base' found. (ActiveRecord::ConnectionNotEstablished)
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/connection_handling.rb:309:in `retrieve_connection'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/connection_handling.rb:265:in `connection'
    from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:554:in `load_schema!'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/attributes.rb:264:in `load_schema!'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:540:in `block in load_schema'
    from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:537:in `synchronize'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:537:in `load_schema'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/model_schema.rb:461:in `_default_attributes'
    from /Users/shigerunakajima/rails/activerecord/lib/active_record/core.rb:575:in `initialize'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/inheritance.rb:55:in `new'
  from /Users/shigerunakajima/rails/activerecord/lib/active_record/inheritance.rb:55:in `new'
    from (irb):2:in `<main>'
  from /Users/shigerunakajima/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/irb-1.3.5/exe/irb:11:in `<top (required)>'
  from /Users/shigerunakajima/.rbenv/versions/3.0.1/bin/irb:23:in `load'
    from /Users/shigerunakajima/.rbenv/versions/3.0.1/bin/irb:23:in `<top (required)>'
  from /Users/shigerunakajima/.rbenv/versions/3.0.1/lib/ruby/3.0.0/bundler/cli/exec.rb:63:in `load'
  ... 14 levels...

activerecord/lib/active_record/core.rb:575:in `initialize'

が、気になります。coreクラスのinitializeってめっちゃそれっぽい名前です。

https://github.com/rails/rails/blob/bbbc861f717689a0a28f031fe2176d0f8a6b07c7/activerecord/lib/active_record/core.rb#L573-L584

    def initialize(attributes = nil)
      @new_record = true
      @attributes = self.class._default_attributes.deep_dup

      init_internals
      initialize_internals_callback

      assign_attributes(attributes) if attributes

      yield self if block_given?
      _run_initialize_callbacks
    end

まさにinitializeって感じの処理です。

self.class._default_attributes.deep_dup

めちゃめちゃ怪しい感じの名前が見つかりました。

_default_attributes

https://github.com/rails/rails/blob/5877562f569a1e8019a5492fa42dbdd5e0dcf1af/activerecord/lib/active_record/model_schema.rb#L460-L463

      def _default_attributes # :nodoc:
        load_schema
        @default_attributes ||= ActiveModel::AttributeSet.new({})
      end

全然それっぽくないんですよ。 空のオブジェクトをセットするだけに見えます。

load_schemaは怪しいので追いかけてみましょう。 スタックトレースを見ると、load_schemaはその先でload_schema!を呼んでいます。

https://github.com/rails/rails/blob/5877562f569a1e8019a5492fa42dbdd5e0dcf1af/activerecord/lib/active_record/model_schema.rb#L549-L568

        def load_schema!
          unless table_name
            raise ActiveRecord::TableNotSpecified, "#{self} has no table configured. Set one with #{self}.table_name="
          end

          columns_hash = connection.schema_cache.columns_hash(table_name)
          columns_hash = columns_hash.except(*ignored_columns) unless ignored_columns.empty?
          @columns_hash = columns_hash.freeze
          @columns_hash.each do |name, column|
            type = connection.lookup_cast_type_from_column(column)
            type = _convert_type_from_options(type)
            warn_if_deprecated_type(column)
            define_attribute(
              name,
              type,
              default: column.default,
              user_provided_default: false
            )
          end
        end

default: column.default

これは怪しいですね。これはcolumnがどんなクラスで、どんな値を持っているか是非知りたいところです。 ところがcolumnを取得する前にconnectionを取るところで例外が起きます。

というのは、DBの設定を一切せずにActiveRecordのサブクラスを扱っているからです。 ここから先は、DBを用意した方が良さそうです。

といったところで、今日は時間切れです。