Ruby
Rails

RailsのschemarbをパースしてRubyオブジェクト化するライブラリskippaを作った

Railsのschema.rbをパースしてRubyオブジェクト化するライブラリSkippaを作った。

デモ

schema.rb
ActiveRecord::Schema.define(version: 20180522032741) do
  enable_extension "plpgsql"
  enable_extension "hstore"

  create_table "access_log", id: false, force: :cascade do |t|
    t.integer "user_id"
    t.datetime "timestamp", limit: 8
  end

  create_table "users", force: :cascade do |t|
    t.string "email", limit: 255, default: "", null: false
    t.string "password", limit: 255, default: "", null: false
  end

  add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree
end
skippa = Skippa.parse(File.read("schema.rb"))
p skippa.extentions.map(&:name)                        # => ["plpgsql", "hstore"]
p skippa.tables.map(&:name)                            # => ["access_log", "users"]
p skippa.tables.first.columns.map(&:name)              # => ["user_id", "timestamp"]
p skippa.indexes.map { |index| index.options["name"] } # => ["index_users_on_email"]

pp skippa
#<Skippa::Schema:70133050106360
  info={"version"=>"20180522032741"},
  extentions=[
    #<Skippa::Extention:70133066763800 name="plpgsql">,
    #<Skippa::Extention:70133066763780 name="hstore">,
  ],
  tables=[
    #<Skippa::Table:70133066762020
      name="access_log",
      comment=nil,
      options={"id"=>"false", "force"=>"cascade"},
      columns=[
        #<Skippa::Column:70133070812600
          name="user_id",
          type="integer",
          options={},
        >,
        #<Skippa::Column:70133070812580
          name="timestamp",
          type="datetime",
          options={"limit"=>"8"},
        >,
      ],
    >,
    #<Skippa::Table:70133066762000
      name="users",
      comment=nil,
      options={"force"=>"cascade"},
      columns=[
        #<Skippa::Column:70133070795320
          name="email",
          type="string",
          options={"limit"=>"255", "default"=>"", "null"=>"false"},
        >,
        #<Skippa::Column:70133070795300
          name="password",
          type="string",
          options={"limit"=>"255", "default"=>"", "null"=>"false"},
        >,
      ],
    >,
  ],
  indexes=[
    #<Skippa::Index:70133070776100
      table_name="users",
      column_names=["email"],
      options={"name"=>"index_users_on_email",
       "unique"=>"true",
       "using"=>"btree"},
    >,
  ],
>

モチベーション

Railsで作られたプロジェクトの一部、あるいは全てを別のライブラリへ置き換えたい。
一気に置き換えるのではなく、継続的に徐々に置き換えることを目指すとすると、RailsやDBの変更に苦労なく追随させていきたい。
そこでRailsが出力するschema.rbの情報を保持したRubyオブジェクトを生成するライブラリを作った。一度Rubyオブジェクトになってしまえば、そのオブジェクトを操作して何かをするのは容易である。

実装

標準添付されているRubyプログラムのパーサRipperでschema.rbをS式に変換したものを利用して、例えばtableの取得のように sexp.dig(...) でデータ構造にアクセスしている。

感想

schema.rbの生成を行っているschema_dumper.rbの再利用も検討したが私には難しくあきらめた。例えばschema_dumper.rbのtable生成部分を見てみると手続き的な記述であることがわかるだろう。DBから取得している内容をストリームで扱うために逐次の処理になっているのが大きな要因になっていそうだ。

仮にDBから取得した内容を保持しておき、全て取得し終わってから書き出すと私にはわかりやすいプログラムにできそうだと思った。しかしその場合、DBから出力される情報の量は初める前にはわからないため、DBから取得した情報がRubyのプロセスが抱え切れない量になったときのことを考えなくてはいけなかったり、DBの処理が終わってから書き出し処理を初めるため処理時間が長くなったりするだろう。そういうことを嫌ったのかもしれない。

また、もしschema.rbがArrayとHashのデータ構造になってくれていればこんなことをしなくてもよかった。今でもDSLは好きだけれども、schema.rbに関して言えば人間が書きはしないだろうし、単なるデータ構造でも十分ではないだろうか。

とはいえDSLは単なるRubyプログラムであり、RubyプログラムならRipperで何とかなりそうだということがわかったのは収穫だった。

興味を持ってくれた人へのお願い

私の手元のschema.rbをSkippaで解析してみたらまあまあ動いたように見えるがまだ未実装なところも多いだろう。
そこでみなさんの手元のschema.rbをSkippaで解析してみて動かなかったら、そのときのschema.rbと期待している結果を書いてIssueやPull Requestしてほしい。