データ投入を行う gem をとして Sowing というものを新しく作った。
まだ Active Record しか対応していないが、将来的には Ruby だけで動くようにしたい。
動機
Rails の rake db:seed
でいい感じにデータを入れたかったが、既存の gem はいまいちだと思う。
例えば seed-fu は find_or_create_by
で十分だと思うし、 seedbank も load
や require
を使えば十分だから必要性を感じない。
sprig という gem が結構良かった。Yaml からデータを投入できるし、必要であれば Ruby でもコードを書ける。こういう小回りが効く感じが良い。
でも sprig は既に投入されているデータかどうかを識別するためのキーを Yaml 中で指定することが嫌だった。しかもこの機能は Yaml でしか使えなかった。sprig そのものは CSV と JSON にも対応しているのに、実質 Yaml 以外ではうまく使えないのだ。
そんなわけでちょっと自分で作ることにした。
方針
大まかにはこうだ。
- データは CSV か Yaml で定義できる
- Ruby のコードの中で明示的にデータをロードする (fixturesのように暗黙的にロードしない)
- データの作成、更新、作成済みであれば無視、の3つができる
-
rake db:seed
コマンドに依存しない (seed 以外のデータ投入でも使いたいから) - データ(CSV, Yaml)はあくまでもデータして考え、データをどのように取り込むかは呼び出す側のコードで考える (つまり relational data を erb で指定するようなことはしない)
CSVが嫌いな人は多いだろうけど、人類がCSVとExcelから逃げられない現実と向き合う必要がある。
顧客から渡されるマスターデータは十中八九CSVだ。
CSVをYamlに変換するツールは多々あるが、別にCSVから直接データを読み込んで悪いことはない。
忌々しいことにExcelはUTF-8のCSVをうまく処理できないから日本語に場合文字コードは変換する必要があるかもしれないけど、それでもCSVはUTF-8で保存することをおすすめする。
Github上で表として表示してくれるし、googleスプレッドシートならUTF-8でも編集できるからだ。
Sowing の使い方
create メソッド
Sowing は rake db:seed
コマンドに依存していないからどこでも使えるけど、ここでは db/seeds.rb
の中で使うケースを考えよう。
db/seeds.rb
の中身が次であるとする。
require 'sowing'
runner = Sowing::Runner.new
runner.create(User)
Sowing は主に Sowing::Runner
のインスタンスに処理を定義してある。
runner.create(User)
を実行すると、 Sowing はデータディレクトリ中に users.csv
か users.yml
がないか探しに行ってファイル中のデータを作成する。
データディレクトリはデフォルトで db/seeds/
だ。
db/seeds/users.csv
が次の内容で存在するとする。
first_name,last_name
Carlotta,Wilkinson
中平,薫
first_name
と last_name
が User
モデルのカラムとして存在していなければならない。
runner.create(User)
を実行すると、Carlotta
と 中平
のデータが作成される。
create_or_skip メソッド
Sowing::Runner#create
は必ずデータを作成する。
しかし、rake db:seed
を複数回実行したときに、既に登録されているデータは無視したい場合がある。
この場合、 create_or_skip
メソッドを使う。
require 'sowing'
runner = Sowing::Runner.new
runner.create_or_skip(User, :first_name)
create_or_skip
は create
とほぼ同じだが、 第2引数に指定したキーでデータを検索して、見つかった場合には何もせずに次のデータの処理にうつる。
これによりデータファイル(CSVかYaml)中に定義したデータは1度しかDBに取り込まれない。
create_or_update メソッド
require 'sowing'
runner = Sowing::Runner.new
runner.create_or_update(User, :first_name)
create_or_update
は create_or_skip
とほぼ同じだが、名前の通り、登録しようとしたデータがDBで見つかった場合、スキップせずに値を更新する。
Relational data
他のテーブルに関連するデータを投入する機能もある。
(この機能は作ったばかりだからもし動かなかったら教えてほしい。)
例えば、次の内容の db/seeds/profiles.csv
が存在する。
user_id,address,phone
"first_name: Carlotta","2001 N Clark St, Chicago, IL 60614 America","+1 111-222-3333"
"first_name: 中平","東京都新宿区新宿1-1-1","090-1111-2222"
これを処理するためのコードは次:
require 'sowing'
runner = Sowing::Runner.new
runner.create(User)
runner.create(Profile) do
mapping :user_id do |cel|
User.find_by!(
first_name: cel['first_name']
).id
end
end
まず、 runner.create(User)
で普通に User データを作成する。
次の runner.create(Profile)
には block がついている。
block の中では mapping
メソッドが使える。
mapping
メソッドは指定したカラム名の入力しの変換ルールを定義することができる。
profiles.csv
の各行を処理する時、 mapping
で定義した変換ルールが適用される。
例えば、 profiles.csv
の user_id
の最初のデータは "first_name: Carlotta"
だ。
mapping
の block の第1引数(上記のコードでは cel
)にはデータファイルの文字列が Hash に変換されて渡されている。
つまり、文字列の "first_name: Carlotta"
が Hash の {"first_name" => "Carlotta"}
として cel
に格納される。
mapping
の block では、データファイル上の情報からDBに登録するときに必要な情報に変換するコードを書く必要がある。
この場合、 cel['first_name']
を使って User の first_name が 'Carlotta' に一致するデータを探して、その ID を返す。
これにより、 Profile を DB に登録する際には、 profile.user_id = User.find_by!(first_name: 'Carlotta').id
と同じような処理が行われることになる。
環境毎に seed データを変更する
これは Sowing と直接は関係ないが、よく使うテクニックなので書いておく。
Rails.env
に応じて seed データを変更したいケースはよくある。
これには特別な gem は必要ない。
まずディレクトリ構造:
db
├── seeds/
│ ├── development/
│ ├── production/
│ ├── test/
│ ├── development.rb
│ ├── production.rb
│ └── test.rb
└── seeds.rb
で、 db/seeds.rb
の中を下記にする:
# db/seeds.rb
path = Rails.root.join('db', 'seeds', "#{Rails.env}.rb")
load path if path.exist?
これで、環境に応じて db/seeds/development.rb
や db/seeds/production.rb
などを使うことができるようになった。
ただし、 Sowing のデフォルトのデータディレクトリは db/seeds
なので、次のようにする必要がある。
# db/seeds/development.rb
require 'sowing'
sowing = Sowing::Runner.new(data_directory: 'db/seeds/development')
sowing.create(User)
まとめ
データ投入なんて多少コードが汚くても別に良いんだけど、ごちゃごちゃしてくるとどういうデータができるのかよくわからないし、データ投入に FactoryGirl とかを使うのも嫌だし、こういうYamlやCSVからDBにデータを取り込めるだけのちょっとした gem があると便利なんじゃないかと思う。
残念ながらまだ ActiveRecord でしか動かないから、今後はもっと Pure Ruby で動くようにしていきたい。 ActiveRecord に依存した部分は Strategy パターンで切り出してあるから、おそらくそのうち他の gem に切り分けると思う。
正直テストもそんなに書いてないからうまく動かないことがあるかもしれないけど、 issue とかで教えてくれたら嬉しい。