Rails探訪 ~ create_table 編 ~

開発本部エンジニアの増山(@nyangryy)です。

普段Railsを使った開発の中でハマった内容や、調査の過程を紹介していきたいと思います。

今回はRailsのcreate_tableで、idカラムの型を変更しようとしてハマった話です。

TL;DR

  • Railsでcreate_tableでidカラムの型を変更するときは、
    create_tableのオプションにid: falseを渡した上で、idカラムの定義を書く。
  • Ruby, Rails楽しい。もっとソースコード読もう。

動作確認環境

Ruby 2.1.2
Rails 3.2.17
MySQL 5.6

探訪ログ

とあるタスクで、新しいモデルを作成する機会がありましたが、将来を見越してテーブルのidカラムをBIGINTにする必要がありました。

まずは、思考停止で

rails g model attachment id:bigint name:string

と打ち込み、マイグレーションファイルを作成します。

実行結果は以下のようになりました。おや?なんだかこのままmigrateできそうですね。

$ rails g model attachment id:bigint name:string

invoke  active_record
create    db/migrate/20140808012932_create_attachments.rb
create    app/models/attachment.rb
invoke    rspec
create      spec/models/attachment_spec.rb

$ cat db/migrate/20140808012932_create_attachments.rb

class CreateAttachments < ActiveRecord::Migration
  def change
    create_table :attachments do |t|
      t.bigint :id
      t.string :name

      t.timestamps
    end
  end
end

さっそくrake db:migrateしてみます。

$ rake db:migrate

undefined method `bigint` for #<ActiveRecord::ConnectionAdapters::TableDefinition:0x007ff4f3ddb1d0>
...

bigintなんてメソッド知らねえと怒られました。

どうしてbigintというメソッドが存在しないのでしょうか。
integerにすれば動くのでしょうが、それではidカラムをBIGINTにできません。

ただ、このエラーから1つの知見が得られました。

tというのは、ActiveRecord::ConnectionAdapters::TableDefinitionのインスタンスで、
bigintというインスタンスメソッドを実行しようとして怒られており、どうやらエラーの起きないintegerstringは、きちんとインスタンスメソッドとして定義されているらしいということです。

ということは、エラーを吐いているActiveRecord::ConnectionAdapters::TableDefinitionのメソッド定義を追いかけて行くことで、なにか情報が得られそうだと見当が付きます。

ということで、Railsのソースコードを散策していきます。

bigintメソッドがない理由

GitHubのRailsリポジトリで検索をかけるとどうやらこの辺りでしょうか、
v3.2.17/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L62

class TableDefinition
  # An array of ColumnDefinition objects, representing the column changes
  # that have been defined.
  attr_accessor :columns

  def initialize(base)
    @columns = []
    @columns_hash = {}
    @base = base
  end
  ...

さらにメソッド定義を追いかけていくと、臭い部分が見つかりました。

...
%w( string text integer float decimal datetime timestamp time date binary boolean ).each do |column_type|
  class_eval <<-EOV, __FILE__, __LINE__ + 1
    def #{column_type}(*args)                                   # def string(*args)
      options = args.extract_options!                           #   options = args.extract_options!
      column_names = args                                       #   column_names = args
      type = :'#{column_type}'                                  #   type = :string
      column_names.each { |name| column(name, type, options) }  #   column_names.each { |name| column(name, type, options) }
    end                                                         # end
  EOV
end
...

string, integerを始めとして、マイグレーションでお世話になる単語が並んでいます。
stringintegerはここでclass_evalを使って、Rubyお得意のメタプログラミングでメソッドとして定義されていることがわかりますね。

寄り道

ところで、ここで使用されているヒアドキュメントのEOVってどういう意味なんでしょうか・・

EOS(End Of String)というのはよく見かけますが、気になります。

気になるのでRubyのドキュメント『[Ruby] ヒアドキュメント (行指向文字列リテラル)』を見てみると、単に識別子であって、名前は何でも良さそうです。
そうなるとますます、EOVがなんの略語なのか気になりますが、ここで行き止まりのようです。

もう1点気になるのが、__FILE____LINE__の使い方です。ヒアドキュメントの引数かと思いましたが、これはclass_evalの引数で、スタックトレースに表示される情報を差し替えているのだとわかりました。
[Ruby] Module#class_eval
メタプログラミングで動的にメソッドを追加しつつ、スタックトレースにも正確な情報を載せる気づかいが伺えますね。

脱線してしまいましたが、ここでbigintなんてメソッドが定義されていないことがわかりました。

integerメソッドにlimitオプションを渡せばよさそう

bigintで楽ができないとなると、ここに見えている選択肢から、integerでゴニョゴニョするしかなさそうです。

先ほどのコードで、integerがメタプログラミングで生成されていることがわかりましたが、もう少し追いかけてみると、最後にcolumnメソッドにオプションを含めて丸投げしていることがわかります。

このcolumnメソッドは、同じファイル内で定義されていますが、この定義の少し前に、オプションについて参考になりそうなコメントがありました。

# Available options are (none of these exists by default):
# * <tt>:limit</tt> -
#   Requests a maximum column length. This is number of characters for <tt>:string</tt> and
#   <tt>:text</tt> columns and number of bytes for <tt>:binary</tt> and <tt>:integer</tt> columns.

要するに、limitオプションを使用することで、stringまたはtextの場合は文字数が、
binaryまたはintegerの場合は、バイト数を指定できるようです。

ということは、integerとしてカラムを定義しつつ、limitオプションに適切なバイト数を指定すれば目的が達成できそうです。

寄り道

あ、ちなみにlimitオプションは指定しなくても、データベースに応じて勝手に初期値を設定してくれます。
columnメソッドにlimitオプションが指定されていない場合の処理が書かれていますが、
nativeというメソッドの返り値のハッシュを参照していますね。

limit = options.fetch(:limit) do
  native[type][:limit] if native[type].is_a?(Hash)
end

このnativeメソッドは同じファイル内で定義されていて、native_database_typesというメソッドを呼んでいるようです。

def native
  @base.native_database_types
end

GitHubで検索をかけると、PostgreSQLとMySQLのための抽象アダプタが引っかかります。
きっとデータベースに応じてこれらのアダプタがオーバーライドするのでしょうから、
v3.2.17/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
を見てみます。native_database_typesが定義されていて、その返り値はNATIVE_DATABASE_TYPESという定数になっています。

def native_database_types
  NATIVE_DATABASE_TYPES
end

さらにNATIVE_DATABASE_TYPESを探すと、同じファイル内にありました。

NATIVE_DATABASE_TYPES = {
  :primary_key => "int(11) DEFAULT NULL auto_increment PRIMARY KEY",
  :string      => { :name => "varchar", :limit => 255 },
  :text        => { :name => "text" },
  :integer     => { :name => "int", :limit => 4 },
  :float       => { :name => "float" },
  :decimal     => { :name => "decimal" },
  :datetime    => { :name => "datetime" },
  :timestamp   => { :name => "datetime" },
  :time        => { :name => "time" },
  :date        => { :name => "date" },
  :binary      => { :name => "blob" },
  :boolean     => { :name => "tinyint", :limit => 1 }
}

ビンゴです。integerlimitオプションを指定していなくても、
ここで定義された:limit => 4が自動的に使われるようです。

また寄り道してしまいましたが、ここでintegerのデフォルト値が:limit => 4であることに注目すると、こんなページを思い出しませんか(こじつけです)。
[MySQL] データタイプが必要とする記憶容量

ここで「数値タイプが必要とする記憶容量」という表に注目してください。
INTEGERの場合、4バイトの容量が必要だそうです。4バイト・・ (あ!この数字、進研ゼミで見たやつだ!)
どこかで見た気がします。そうです、:limit => 4こいつです。

なるほど、デフォルトでINTEGERが必要とする4バイトを指定しているのですね。
ということは先ほどの表でBIGINTが必要とする8バイト、
つまり:limit => 8をオプションとして渡せばよさそうなことがわかりました。

さらに寄り道

厳密にはlimitオプションで指定した数字は、おおよそバイト数と対応しているのは間違いないのですが、ここからさらにSQL文を生成するロジックの中で、マッピングに使われる値になっています。(泥臭い・・)
v3.2.17/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
このファイル内で定義されているtype_to_sqlメソッドの中で、limitオプションで指定した値に応じて、マッピング処理が行われています。

def type_to_sql(type, limit = nil, precision = nil, scale = nil)
  case type.to_s
  when 'integer'
    case limit
    when 1; 'tinyint'
    when 2; 'smallint'
    when 3; 'mediumint'
    when nil, 4, 11; 'int(11)'  # compatibility with MySQL default
    when 5..8; 'bigint'
    else raise(ActiveRecordError, "No integer type has byte size #{limit}")
    end
  when 'text'
    case limit
    when 0..0xff;               'tinytext'
    when nil, 0x100..0xffff;    'text'
    when 0x10000..0xffffff;     'mediumtext'
    when 0x1000000..0xffffffff; 'longtext'
    else raise(ActiveRecordError, "No text type has character length #{limit}")
    end
  else
    super
  end
end

limitオプションがスルーされてる・・?

やっと、integerメソッドにlimitオプションを指定することでBIGINTにできそうだとわかったので、早速マイグレーションファイルを修正してみます。

$ vim db/migrate/20140808012932_create_attachment.rb

class CreateAttachment < ActiveRecord::Migration
  def change
    create_table :attachment do |t|
      t.integer :id, limit: 8
      t.string :name

      t.timestamps
    end
  end
end

そしてmigrateを走らせます。

$ rake db:migrate

==  CreateAttachment: migrating ===============================================
-- create_table(:attachment)
   -> 0.0269s
==  CreateAttachment: migrated (0.0270s) ======================================

お、なんだかうまくいったようですね!
早速MySQLのテーブル定義を確認します。

なにか変です

なにか変ですね・・INTEGERになってます・・。limitオプションはスルーされるのでしょうか・・・?

悪あがきで別のカラムを適当に作って同じことをやってみます・・

$ vim db/migrate/20140808012932_create_attachment.rb

class CreateAttachment < ActiveRecord::Migration
  def change
    create_table :attachment do |t|
      t.integer :id, limit: 8
      t.string :name
      t.integer :test_id, limit: 8

      t.timestamps
    end
  end
end

あれ・・・これはどういうことでしょうか。

なにか変です2

パッと思いつくのが、idという名前に原因があるんじゃないかということ。
idに的を絞って再びRailsのソースコードを散策してみます。

create_tableメソッドのオプションの秘密

闇雲にidで検索をかけてもキリがないので、
t.integerといった定義ブロックを実行しているcreate_tableメソッドでも見に行きましょう。

おや、早速create_tableメソッドのコメントで臭そうなところを見つけました。
v3.2.17/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L106

# The +options+ hash can include the following keys:
# [<tt>:id</tt>]
#   Whether to automatically add a primary key column. Defaults to true.
#   Join tables for +has_and_belongs_to_many+ should set it to false.
# [<tt>:primary_key</tt>]
#   The name of the primary key, if one is to be added automatically.
#   Defaults to +id+. If <tt>:id</tt> is false this option is ignored.
#
#   Also note that this just sets the primary key in the table. You additionally
#   need to configure the primary key in the model via +self.primary_key=+.
#   Models do NOT auto-detect the primary key from their table definition.

どうやらcreate_tableメソッドにはオプションが設定できるようです。

:id
  プライマリキーを自動的に追加するかどうか(デフォルトはtrue)
:primary_key
  プライマリキーの名前を指定する(デフォルトはid)
  idオプションがfalseなら無視される

なるほどなるほど、デフォルトでidオプションがtrueになっているので、何も考えなくても勝手にidカラムがプライマリキーとして追加されるわけですね。
(普段意識しない部分ですが、本当に良く出来ていますね。)

これを踏まえて、さらにcreate_tableメソッドを読み進めていきます。

def create_table(table_name, options = {})
  td = table_definition
  td.primary_key(options[:primary_key] || Base.get_primary_key(table_name.to_s.singularize)) unless options[:id] == false
  ...

ここで登場するtable_definitionというのは、
最初のほうで見たActiveRecord::ConnectionAdapters::TableDefinitionのインスタンスを返していて、さらにprimary_keyというインスタンスメソッドを呼んでいるので、
v3.2.17/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
このファイルの中で、primary_keyメソッドの挙動を調べてみます。

def primary_key(name)
  column(name, :primary_key)
end

単純に引数として渡したname(デフォルトで:id)を、columnメソッドに丸投げしています。

columnメソッドの中では、

column = self[name] || new_column_definition(@base, name, type)

として、
ActiveRecord::ConnectionAdapters::TableDefinitionのインスタンスのハッシュ?にnameキーが存在していなければ、new_column_definitionメソッドを呼んだ結果を、キーが既に存在していればself[name]を返していることがわかります。

寄り道

このself[name]、さり気なく呼ばれていますが、不思議ですね。
[]記法はハッシュにアクセスする記法のはずですが、今selfActiveRecord::ConnectionAdapters::TableDefinitionのインスタンスのはずです。これはどうみてもシンタックスエラーです。本当にありがとうございました・・・とはならず、ここにもRubyの楽しい挙動が利用されています。

ActiveRecord::ConnectionAdapters::TableDefinitionの定義を調べていくと、
v3.2.17/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb

class TableDefinition
  ...
  def initialize(base)
    @columns = []
    @columns_hash = {}
    @base = base
  end
  ...

  # Returns a ColumnDefinition for the column with name +name+.
  def [](name)
    @columns_hash[name.to_s]
  end

  ...
  private
  def new_column_definition(base, name, type)
    definition = ColumnDefinition.new base, name, type
    @columns << definition
    @columns_hash[name] = definition
    definition
  end
  ...

なんと[]ActiveRecord::ConnectionAdapters::TableDefinitionのインスタンスメソッドだったのですね!
よく見るとinitialize処理で、@columns_hashというインスタンス変数を定義していました。
[]メソッドはこの@columns_hashにアクセスするためのメソッドだったのですね。
ワクワクしますね〜こういう挙動!Rubyって楽しいなあ!

ここで、create_tableメソッドに戻ると、primary_keyメソッドを実行した後に、

yield td if block_given?

ブロックを実行していることがわかります。このブロックこそ、

create_table :attachment do |t|
  t.integer :id, limit: 8
  t.string :name
  t.integer :test_id, limit: 8

  t.timestamps
end

do~endの部分です。ここまでの知見を思い出すと・・・

tというのは、ActiveRecord::ConnectionAdapters::TableDefinitionのインスタンスで、
bigintというインスタンスメソッドを実行しようとして怒られており、
どうやらエラーの起きないintegerstringは、きちんとインスタンスメソッドとして定義されているらしいということです。

~~ 略 ~~

先ほどのコードで、integerがメタプログラミングで生成されていることがわかりましたが、
もう少し追いかけてみると、最後にcolumnメソッドにオプションを含めて丸投げしていることがわかります。

このブロックはprimary_keyメソッドで行っていた、columnメソッドへの丸投げを同じように繰り返しているだけだとわかります。

やっとidカラムの定義がスルーされる理由がわかりましたね。

idカラムの定義がスルーされる理由

ここまでの流れをまとめると、

  • create_tableメソッドが実行されると、デフォルトオプションだと、最初にprimary_keyメソッドが呼ばれる。
  • primary_keyメソッドには、create_tableメソッドからデフォルトで:idが渡る。
  • この時点でActiveRecord::ConnectionAdapters::TableDefinitionインスタンスの@columns_hashには:idキーがセットされた状態になる。
  • この状態で、create_tableメソッドがブロックを実行すると、
    t.integer :id, limit: 8といった定義を、順にcolumnメソッドに丸投げしていくが、
    ActiveRecord::ConnectionAdapters::TableDefinitionインスタンスの@columns_hashには、既に:idというキーがセットされているので、 同じキー:idで定義しようとしてもスルーされる。

そして解決へ・・・

idカラムの定義がスルーされるのを回避するには、
create_tableメソッドで、primary_keyメソッドの呼び出しをさせないようにすれば良いので、

def create_table(table_name, options = {})
  td = table_definition
  td.primary_key(options[:primary_key] || Base.get_primary_key(table_name.to_s.singularize)) unless options[:id] == false
  ...

create_tableメソッドのオプションにid: falseを渡せば良いですね。

create_table :attachment, id: false do |t|
  t.integer :id, limit: 8
  t.string :name

  t.timestamps
end

これでmigrateしてみます・・・

BIGINTにできた!

長かったですね。。やっとidカラムがBIGINTになりました。
しかしここで1つ問題があります。create_tableメソッドにid: falseオプションを渡したことで、プライマリキーの設定が行われていません・・。AUTO_INCREMENTも設定されていません・・・。

primary_keyオプションも使えないことから、結局以下のように定義して解決しました。

create_table :attachment, id: false do |t|
  t.column :id, 'BIGINT PRIMARY KEY AUTO_INCREMENT'
  t.string :name

  t.timestamps
end

ものすごく、イケてないです・・

t.primary_key :idとしても、primary_keyメソッドがオプションを受け取らず、BIGINTに変更できないので、素直に追加のマイグレーションファイルを作ったほうが良いかもしれません。
このままだとMySQL以外のDBでマイグレーションがコケそうです。

なんだか時間をかけた割に、拍子抜けする結果となってしまいましたが、idカラムをBIGINTにするという目的は達成することができました。

Rails4 だとどうなの?

Rails 4.1.4 だとcolumnメソッド周りの書き方がスマートになっていました。
v4.1.4/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb

def column(name, type, options = {})
  name = name.to_s
  type = type.to_sym

  if primary_key_column_name == name
    raise ArgumentError, "you can't redefine the primary key column '#{name}'. To define a custom primary key, pass { id: false } to create_table."
  end

  @columns_hash[name] = new_column_definition(name, type, options)
  self
end

既に@columns_hashでキーが定義済みでも、毎回上書きするようになっていますが、相変わらずプライマリキーの時だけ上書きできず、プライマリキーの定義を上書きしたい場合はcreate_tableメソッドにid: falseを渡す必要があります。

一応primary_keyメソッドが改良されていて、

# Appends a primary key definition to the table definition.
# Can be called multiple times, but this is probably not a good idea.
def primary_key(name, type = :primary_key, options = {})
  column(name, type, options.merge(:primary_key => true))
end

オプションを受け取るようになっていますが、その先のNATIVE_DATABASE_TYPES

NATIVE_DATABASE_TYPES = {
  :primary_key => "int(11) auto_increment PRIMARY KEY",

と、ベタ書き状態なのでオプションでlimitを指定してもマージされなさそうです。

まとめ

Railsのcreate_tableでidカラムの型を変更したい時は、  
create_tableのオプションに id: false を渡した上で、idカラムの定義を書く!

長々と続けてしまいましたが、実は今回やりたかったことはググればすぐにQiitaやStackOverflowで答えが見つかりました。
しかし、ただそこで鵜呑みにするだけでなく、自分で実際の処理の流れを追いかけてみると、解決策以外にその過程で得られる知見(主に寄り道)が多いことに改めて気付かされました。

こういった知見がすぐに実際の業務で役立つことは少ないかもしれませんが、それでもこういった小さな知見を積み重ねていくことで、長い目で見るとエンジニアとして成長しているはずです。

小さな疑問や、好奇心を大事にしながら、何よりその過程を楽しみながらエンジニアライフを送って行きたいと思います。

参考リンク

絶賛エンジニア採用中!

マネーフォワードでは、Vimmerエンジニアを積極的に採用しています!
Vimが大好きな皆様のご応募、お待ちしています!一緒にワクワクしましょう〜!

Pocket