しふみんのブログ

しふみんのブログです。

create_tableでカラムを定義するのと同時にユニークインデックスを貼るやつ

なぜかレビューを通っていた下記のようなmigrationがあったのですが、 unique indexが貼られていなくて(当たり前ですが)先日ひどい目にあいました。

# db/migrate/20181231235959_create_products.rb
class CreateProducts < ActiveRecord::Migration
  def change
    create_table :products do |t|
      # (略)
      t.integer :shop_id, unique: true
      # (略)
    end
  end
end

はい。 ActiveRecord::ConnectionAdapters::TableDefinitioncolumn メソッドの opthionsunique: true を渡してもunique indexは貼られませんね。

create_table でカラムを定義するのと同時に unique index を貼るやつの結論

上記のオプションの渡し方の正解は下記ですね。

t.integer :shop_id, index: { unique: true }

下記でいけるので。

t.type :column_name, index: { unique: true }

下記はだめ。

t.type :column_name, unique: true

そんなオプションの渡し方はない。

ActiveRecord::ConnectionAdapters::SchemaStatements の create_table メソッドのコードを読む

まあ、以上なのですが、せっかくなのでこの部分が実際にどういう実装になっているかを知るために ActiveRecord::ConnectionAdapters::SchemaStatementscreate_table メソッドについて Active Recordのコードを読んでみますか。

読んだ環境はRails 5.2.2 (GitHub - rails/rails at v5.2.2)です。

create_table

https://github.com/rails/rails/blob/v5.2.2/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L290

def create_table(table_name, comment: nil, **options)
  td = create_table_definition table_name, options[:temporary], options[:options], options[:as], comment: comment
  # (中略)
  yield td if block_given?
  # (中略)
end

yield に渡しているのは td なので、普段 t.integer みたいに書いている tcreate_table_definition で定義されているようだ。

create_table_definition table_name

https://github.com/rails/rails/blob/v5.2.2/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L1278

def create_table_definition(*args)
  TableDefinition.new(*args)
end

tdTableDefinitionインスタンスだった。

TableDefinition

https://github.com/rails/rails/blob/v5.2.2/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L257

class TableDefinition
  include ColumnMethods
  # (中略)
end

ColumnMethods をincludeしている。

ColumnMethods

https://github.com/rails/rails/blob/v5.2.2/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L201

module ColumnMethods
  # (中略)
  # Appends a column or columns of a specified type.
  #
  #  t.string(:goat)
  #  t.string(:goat, :sheep)
  #
  # See TableDefinition#column
  [
    :bigint,
    :binary,
    :boolean,
    :date,
    :datetime,
    :decimal,
    :float,
    :integer,
    :json,
    :string,
    :text,
    :time,
    :timestamp,
    :virtual,
  ].each do |column_type|
    module_eval <<-CODE, __FILE__, __LINE__ + 1
      def #{column_type}(*args, **options)
        args.each { |name| column(name, :#{column_type}, options) }
      end
    CODE
  end
  alias_method :numeric, :decimal
end

普段使っているシンタックスシュガーがメタプロで定義されている。
t.integer だと column(name, :integer, options) } なので、結局 column メソッドに options 引数が渡されて実行されている。

column

https://github.com/rails/rails/blob/v5.2.2/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L355

def column(name, type, options = {})
  name = name.to_s
  type = type.to_sym if type
  options = options.dup
  # (中略)
  index_options = options.delete(:index)
  index(name, index_options.is_a?(Hash) ? index_options : {}) if index_options
  @columns_hash[name] = new_column_definition(name, type, options)
  self
end

optionsindex キーの中身は index メソッドに引数として渡されている。

index

https://github.com/rails/rails/blob/v5.2.2/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L380

# Adds index options to the indexes hash, keyed by column name
# This is primarily used to track indexes that need to be created after the tab
#
#   index(:account_id, name: 'index_projects_on_account_id')
def index(column_name, options = {})
  indexes << [column_name, options]
end

コメントの通りですが、 indexesoptions を追加している。

create_tableに戻る

https://github.com/rails/rails/blob/v5.2.2/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L314

def create_table(table_name, comment: nil, **options)
  td = create_table_definition table_name, options[:temporary], options[:options], options[:as], comment: comment
  # (中略)
  unless supports_indexes_in_create?
    td.indexes.each do |column_name, index_options|
      add_index(table_name, column_name, index_options)
    end
  end
  # (中略)
end

td.indexes のそれぞれの要素の2つ目の index_optionsadd_index メソッドに index_options として渡している。
結局 add_index が呼ばれていますね。

--

ActiveRecordのコードはこれ以上追いませんが、結局

t.type :column_name, index: { key: value }

index 部分のオプションは

 add_index(table_name, column_name, {key: value})

のように、 add_index にオプションとして渡されるのでした。
そして add_index は unique indexを作成するときは オプションとして unique: true を渡す必要ので、 create_table の中の t.type でunique indexを貼りたいときはオプションとして index をキーにして、

t.type :column_name, index: { unique: true }

としないといけないのでした。

コードを読むのは疲れますが、実際の処理の中身がよく分かるのでこれからも適宜読んでいきたいですね。
こちらからは以上です。

補足

上記で見た、ActiveRecord::ConnectionAdapters::TableDefinitioncolumn メソッドのオプションでunique indexを貼る方法が以外の方法は下記がある。

ActiveRecord::ConnectionAdapters::SchemaStatementsadd_index メソッド

テーブル作成後にindexを追加するやつ。

create_table :products do |t|
  t.integer :shop_id
end

add_index :products, :shop_id, unique: true, name: 'products_shop_id_index'

ActiveRecord::ConnectionAdapters::TableDefinitionindex メソッド

create_tableの中でindexを追加するやつ。

create_table :products do |t|
  t.integer :shop_id  
   
  t.index :category_id, unique: true, name: 'products_shop_id_index'
end

ActiveRecord::ConnectionAdapters::Tableindex メソッド

change_tableでindexを追加するやつ。
create_table 内で利用する1つ上と形は一緒ですね。

change_table :products do |t|
  t.index :shop_id, unique: true, name: 'products_shop_id_index'
end

参考