開発本部エンジニアの増山(@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
というインスタンスメソッドを実行しようとして怒られており、どうやらエラーの起きないinteger
やstring
は、きちんとインスタンスメソッドとして定義されているらしいということです。
ということは、エラーを吐いている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
を始めとして、マイグレーションでお世話になる単語が並んでいます。
string
やinteger
はここで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 } }
ビンゴです。
integer
のlimit
オプションを指定していなくても、
ここで定義された: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
あれ・・・これはどういうことでしょうか。
パッと思いつくのが、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]
、さり気なく呼ばれていますが、不思議ですね。
[]
記法はハッシュにアクセスする記法のはずですが、今self
はActiveRecord::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
というインスタンスメソッドを実行しようとして怒られており、
どうやらエラーの起きないinteger
やstring
は、きちんとインスタンスメソッドとして定義されているらしいということです。~~ 略 ~~
先ほどのコードで、
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してみます・・・
長かったですね。。やっと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で答えが見つかりました。
しかし、ただそこで鵜呑みにするだけでなく、自分で実際の処理の流れを追いかけてみると、解決策以外にその過程で得られる知見(主に寄り道)が多いことに改めて気付かされました。
こういった知見がすぐに実際の業務で役立つことは少ないかもしれませんが、それでもこういった小さな知見を積み重ねていくことで、長い目で見るとエンジニアとして成長しているはずです。
小さな疑問や、好奇心を大事にしながら、何よりその過程を楽しみながらエンジニアライフを送って行きたいと思います。
参考リンク
- [Ruby] ヒアドキュメント (行指向文字列リテラル)
- [Ruby] Module#class_eval
- [MySQL] データタイプが必要とする記憶容量
- [Rails] v3.2.17/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
- [Rails] v3.2.17/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
- [Rails] v3.2.17/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
- [Rails] v4.1.4/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
絶賛エンジニア採用中!
マネーフォワードでは、Vimmerエンジニアを積極的に採用しています!
Vimが大好きな皆様のご応募、お待ちしています!一緒にワクワクしましょう〜!