🚀

[Rails4]referencesカラムでインデックスを作成するならindexオプションを忘れずに

2022/03/20に公開

概要

こんにちは、M-Yamashitaです。

今回の記事は、Rails4でテーブルを作成する際にreferencesのカラムでインデックスを作成したいならindexオプションが必要の話です。

以下のようにマイグレーションファイルを作成し、マイグレーションさせたところデータベースにインデックスが作成されませんでした。

yyyymmddhhmmss_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration
  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user
      t.timestamps null: false
    end
  end
end

そのため、インデックスを作成するためにはどう書くか、インデックスが作成されるまでのRailsのコードはどうなっているか、indexオプションを付ける経緯はどうなっていたのかについて調べました。

結論(インデックスを作成するためにはどう書くか)

Rails4でテーブル作成時に、referencesのカラムをデータベース上のインデックスとして作成したい場合、以下のようにindex: trueのindexオプションが必要です。
このオプションがなければインデックスは作成されません。

yyyymmddhhmmss_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration
  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user, index: true
      t.timestamps null: false
    end
  end
end

コードリーディング

indexオプションをつけることで、どこでインデックスが作成されるのかコードリーディングして確認します。
create_tableメソッドからコードリーディングを始めます。

activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
      def create_table(table_name, options = {}) #:nodoc:
        super(table_name, options.reverse_merge(:options => "ENGINE=InnoDB"))
      end

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L481

ここは親メソッドを読んでいるだけなので、親クラスのAbstractAdapterを見ます。
ただし、このAbstractAdapterクラス自体にはcreate_tableメソッドはありません。SchemaStatementsをincludeしており、そこにcreate_tableメソッドがあります。

/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
    class AbstractAdapter
      ADAPTER_NAME = 'Abstract'.freeze
      include Quoting, DatabaseStatements, SchemaStatements

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L68
SchemaStatementsモジュールを見てみます。

/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
      def create_table(table_name, options = {})
        td = create_table_definition table_name, options[:temporary], options[:options], options[:as]

        if options[:id] != false && !options[:as]
          pk = options.fetch(:primary_key) do
            Base.get_primary_key table_name.to_s.singularize
          end

          td.primary_key pk, options.fetch(:id, :primary_key), options
        end

        yield td if block_given?

        if options[:force] && table_exists?(table_name)
          drop_table(table_name, options)
        end

        result = execute schema_creation.accept td

        unless supports_indexes_in_create?
          td.indexes.each_pair do |column_name, index_options|
            add_index(table_name, column_name, index_options)
          end
        end

        td.foreign_keys.each do |other_table_name, foreign_key_options|
          add_foreign_key(table_name, other_table_name, foreign_key_options)
        end

        result
      end

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L205

ここでテーブル作成とインデックス作成を行っています。
本件に関係がある部分のみ見ていきます。まずはcreate_table_definitionメソッドです。

active_record/connection_adapters/abstract/schema_statements.rb
      def create_table_definition(name, temporary, options, as = nil)
        TableDefinition.new native_database_types, name, temporary, options, as
      end

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L1031
ここではTableDefinitionのインスタンスを作成するのみとなります。インスタンス作成時の初期化についてはスキップします。

次にyield td if block_given?です。この処理でマイグレーションファイルのcreate_tableのブロックを実行します。ブロックにt.referencesがあった場合、tTableDefinitionクラスなので、そのクラスのreferencesメソッドを呼び出します。

activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
      def references(*args)
        options = args.extract_options!
        polymorphic = options.delete(:polymorphic)
        index_options = options.delete(:index)
        foreign_key_options = options.delete(:foreign_key)
        type = options.delete(:type) || :integer

        if polymorphic && foreign_key_options
          raise ArgumentError, "Cannot add a foreign key on a polymorphic relation"
        end

        args.each do |col|
          column("#{col}_id", type, options)
          column("#{col}_type", :string, polymorphic.is_a?(Hash) ? polymorphic : options) if polymorphic
          index(polymorphic ? %w(type id).map { |t| "#{col}_#{t}" } : "#{col}_id", index_options.is_a?(Hash) ? index_options : {}) if index_options
          if foreign_key_options
            to_table = Base.pluralize_table_names ? col.to_s.pluralize : col.to_s
            foreign_key(to_table, foreign_key_options.is_a?(Hash) ? foreign_key_options : {})
          end
        end
      end

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L312
このメソッドでoptions、つまりreferencesの引数からindexを取り出し、indexが存在すれば、referencesに指定されたカラムを引数としてindexメソッドを呼び出します。

activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
      def index(column_name, options = {})
        indexes[column_name] = options
      end

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L287
indexメソッドではindexesに対象カラムとオプションをセットするのみとなります。

ブロックの処理を抜け、create_tableメソッドに戻ります。
result = execute schema_creation.accept tdを見ていきます。
まずはschema_creationからです。これはSchemaStatementsモジュールのcreate_tableの呼び出し元がAbstractMysqlAdapterクラスなので、そのクラスのメソッドを呼び出します。

      def schema_creation
        SchemaCreation.new self
      end

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L57
このメソッドでActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::SchemaCreationクラスのインスタンスを作ります。

次にそのクラスのインスタンスからacceptメソッドを呼び出します。

activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb
        def accept(o)
          m = @cache[o.class] ||= "visit_#{o.class.name.split('::').last}"
          send m, o
        end

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb#L12
oTableDefinitionクラスのインスタンスなので、visit_TableDefinitionmにセットされ、そのメソッドを呼び出します。

activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
        def visit_TableDefinition(o)
          name = o.name
          create_sql = "CREATE#{' TEMPORARY' if o.temporary} TABLE #{quote_table_name(name)} "

          statements = o.columns.map { |c| accept c }
          statements.concat(o.indexes.map { |column_name, options| index_in_create(name, column_name, options) })

          create_sql << "(#{statements.join(', ')}) " if statements.present?
          create_sql << "#{o.options}"
          create_sql << " AS #{@conn.to_sql(o.as)}" if o.as
          create_sql
        end

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L20
このメソッドではSQL作成を行います。
CREATE TABLE ・・・のクエリを作り、その後必要なクエリを付け加えています。

statements.concat(o.indexes.map { |column_name, options| index_in_create(name, column_name, options) })を見ると、前述のindexesにセットされたインデックスをそれぞれ取り出し、index_in_createメソッドを実行しています。

activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
        def index_in_create(table_name, column_name, options)
          index_name, index_type, index_columns, index_options, index_algorithm, index_using = @conn.add_index_options(table_name, column_name, options)
          "#{index_type} INDEX #{quote_column_name(index_name)} #{index_using} (#{index_columns})#{index_options} #{index_algorithm}"
        end

https://github.com/rails/rails/blob/0ecaaf76d1b79cf2717cdac754e55b4114ad6599/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L51
index_in_createメソッドで、インデックス作成のINDEX ・・・のクエリを作成します。

visit_TableDefinitionメソッドに戻り、さきほどのインデックス作成クエリを結合することでCREATE TABLE・・・ INDEX ・・・のクエリができあがります。

create_tableメソッドに戻り、result = execute schema_creation.accept tdのコードにてexecuteメソッドを実行します。
このexecuteメソッドで作成されたクエリを実行します。

以上で、indexオプションをつけることによりどこでインデックスが作成されるのか、がコード上で確認できました。

なぜデフォルトでインデックスが作成されないか

Rails4以前ではreferencesを設定しても、データベースにインデックスは自動作成されていませんでした。
その後、indexオプションを設定することでインデックスを自動作成できるPull Requestが挙がりました。
https://github.com/rails/rails/pull/5262
このPull Requestでは以下2つを行っています。

  • rake g model lock recording:referencesのようなコマンドを実施することで、マイグレーションファイルにindex: trueのindexオプションを追加する
  • マイグレーションファイルにindexオプションがあればデータベースにインデックスを自動的に作成する

このPull Requestに対しインデックスに関する議論が挙がりました。

I think it's healthy to explicitly add indexes, but I think this should be the default. Nice patch; needs a changelog bump and some guide/docs updates!

このインデックス自動作成はデフォルトにしたほうが良いのではないかとの意見です。これに対し自動作成は避けたいとの意見もありました。

Nice patch! There are a couple things we need to have in mind though:

  1. If we are going to make this approach the default, it needs to be turned on via a flag, otherwise it will break backwards compatibility;

  2. When you use a generator to generate a migration, we already include indexes for each belongs_to/references. So if we are merging this patch, we need to reevaluate our current migration generator.

My $0.02: this patch adds a simpler syntax to add indexes which is welcome but I wouldn't make this the default. I would simply improve the current generators.

後方互換性の面を考えるとデフォルトにすべきではないとのことです。フラグを付けなければならず、古いマイグレーションファイルに対する懸念です。

また、indexオプションがなかったら警告を出したらどうかという意見も挙がりました。
その意見を受けて、もし警告を出す場合はあえて警告を出さないフラグも必要になること、古いマイグレーションファイルすべてに警告を表示しなければならなくなるといった意見もありました。

この結果、インデックス自動作成はデフォルトにはせず、indexオプションをつける結論に至りました。

その後、Rails4リリース後にDHHから、indexオプションなしでデフォルトでデータベースにインデックスを自動作成しようというIssueが挙がりました。
https://github.com/rails/rails/issues/18146

#references should automatically add a index -- it's what you want in 9/10 cases. So instead people can do index: false if they DON'T want the index.

ほとんどのケースでreferencesにはインデックスが必要とのことです。
このときは後方互換性に関する議論もなく、以下Pull Requestが作成され、Rails5の機能として取り込まれました。
https://github.com/rails/rails/pull/23179

https://github.com/rails/rails/blob/5-0-stable/activerecord/CHANGELOG.md

Using references or belongs_to in migrations will always add index for the referenced column by default, without adding index: true option to generated migration file. Users can opt out of this by passing index: false.
Fixes #18146.

この結果、Rails5以降においては、テーブル作成時にindexオプションなしでデフォルトでインデックスが作成されるようになりました。

おわりに

今回の記事では、Rails4でテーブル作成時、referencesのカラムでインデックスを作成したいならindexオプションが必要の話をしました。
デフォルトで自動作成されるものと思い込んでいたので、認識を改めることができました。
この記事が誰かのお役に立てれば幸いです。

Discussion