ARはMySQLでtinyint(1)をbooleanにエミュレートする

メモ。

By default, the MysqlAdapter will consider all columns of type tinyint(1) as boolean. If you wish to disable this emulation (which was the default behavior in versions 0.13.1 and earlier) you can add the following line to your environment.rb file:

http://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/MysqlAdapter.html

回避するときは

  ActiveRecord::ConnectionAdapters::MysqlAdapter.emulate_booleans = false


うーん、どうしようかなぁ…。
truefalseだけじゃ足りないけどintegerは勿体ない、みたいなカラムがある。

  1. boolean
  2. tinyint
  3. integer

現状のmigrationだと2を3に合わせる必要がある。
このオプションをオンにすると、1を2に合わせてやる必要があるんだろうな。

MySQLのMigrationで:tinyintを有効にする方法

DBMSのカラムタイプはMigrationで定義している抽象化されたシンボルをキーにした各アダプタで定義されたハッシュから取得しているので、このハッシュに定義されていないもの、例えば

add_column(table_name, column_name, "tinyint", options = {}) 

と言った記述は容赦なくnilとなる(たぶん)。
試してみると、ハッシュにない場合は引数の文字列がそのままカラム名となった。
実際にARのソースを見るとそうなっている。

>>|ruby|
def type_to_sql(type, limit = nil, precision = nil, scale = nil) #:nodoc:
native = native_database_types[type]
column_type_sql = native.is_a?(Hash) ? native[:name] : native
#(以下略)
end

|

ARの実装とRuby処理系のTimeに関する実装でハマる

ActiveRecordSQLServerからMySQLにデータを移行するスクリプトを書いているときに躓く。
正直全部書ききれないので端折って結論だけ書いてしまう。
Windowsの処理系においてTime.localメソッドはGMTとの時刻差を前提に入れて書いておかないとハマる。
これ一つで半日くらい使ってしまった…。

具体的に言うと、Windowsruby 1.8.5の環境では

>Time.local(1970,1,1)
ArgumentError: time out of range
>Time.local(1970,1,1,9)
=> Thu Jan 01 09:00:00 +0900 1970

と言う結果になる。
RHEL4の環境で同じように試してみると

> Time.local(1970,1,1)
=> Thu Jan 01 00:00:00 +0900 1970
> Time.local(1970,1,1,9)
=> Thu Jan 01 09:00:00 +0900 1970

と言う結果に。


Rubyリファレンスマニュアルを見ると

Time オブジェクトは時刻を起算時からの経過秒数で保持しています。起算時は協定世界時(UTC、もしくはその旧称から GMT とも表記されます) の 1970年1月1日午前0時です。なお、うるう秒を勘定するかどうかはシステムによります。

http://www.ruby-lang.org/ja/man/index.cgi?cmd=view;name=Time

と書かれているので、恐らくWindowsの処理系では先にGMTによる内部補正を実行してからシステム時間を取得(?)していると考えられる。RHEL4の方だと先にシステム時間を取ってきてから後でGMTの時刻を補正しているんだろう。超適当な推測…。
中身を見てないからWindows側の問題なのかRuby処理系側の問題なのかなんとも言えないけど、使う側から見るとRHEL4のように動いて欲しい。

で、なんでARの処理系なのかと言うと

ActiveRecordではDBMSを意識しないでデータを扱えるように各DBMS毎にラッパ(アダプタ?)が合る。
\lib\active_record\connection_adapters以下にあるファイル達がそれ。
SQLServer用のアダプタはsqlserver_adapter.rbである。
んで、この中で日付型の変換ロジックがある。

      def cast_to_time(value)
        return value if value.is_a?(Time)
        time_array = ParseDate.parsedate(value)
        Time.send(Base.default_timezone, *time_array) rescue nil
      end

SQLServerから取ってきた日付型の各要素を配列に分割してタイムゾーンに合わせてTimeクラスのメソッドを呼んでいる。
特に何もしていなければたぶんTime.localメソッドが実行される。
この時にWindowsだとかGMTだとかをまったく考慮していないので(当たり前だけど)、例えばSQLSever側に「1970-01-01 08:59:59」と言うようなデータがあるとTime.local(1970,1,1,8,59,59)のような形でメソッドが実行される。
この時にさっき書いたGMTの問題で、Windows系だと例外が起こる。
結果、nilが返される。

混乱に拍車をかけるARの実行結果

ARでカラムの値を取ってくる方法はAR#カラム名の他に、AR#attributesって言う方法もある。
取ってきたレコードの全部のカラムに共通処理(文字コード変換とか)を加えたい場合にイテレータっぽく使ってた。
今回のエラーが起こった時、この二つのメソッドでそれぞれ実行結果が異なると言うなんとも怪しい現象が。

>hoge = SQLServer.find_by_sql(sql)
>pp hoge
#"1970/01/01 08:59:59"


>pp hoge[0].attributes
{"create_date"=>nil,


>pp hoge[0].create_date
nil


>pp hoge[0].create_date
=> #


>pp hoge[0].create_date.to_s
=> "1970-01-01T08:59:59Z"


もう怪しさ100%。
特に怪しいのは、pp hogeとpp hoge[0].attributesで実行結果が違うこと、hoge[0].create_dateの一回目と二回目で実行結果が変わる事だろう。
前者だけ簡単に調べた。
hoge[0].attributesメソッドは単にArrayを返すだけでなく、上記の日付型変換処理を行っている。
変換処理をしない状態の値が欲しい場合用にAR#attributes_before_type_castと言うメソッドがあった。


後者については正直よく分からず…。method_missingを拾っているのだけれど、そこからの処理内容がよくわからじ。
時間があるときに調べたい。

結論

  • rescue nilは止めよう
  • 処理系による違いを意識しよう
  • 今回の件でActiveRecordの印象ややダウン