Sowing - データ投入を行うgemについて

データ投入を行う gem をとして [Sowing](https://github.com/kbaba1001/sowing) というものを新しく作った。
まだ Active Record しか対応していないが、将来的には Ruby だけで動くようにしたい。

## 動機

Rails の `rake db:seed` でいい感じにデータを入れたかったが、既存の gem はいまいちだと思う。
例えば [seed-fu](https://github.com/mbleigh/seed-fu) は `find_or_create_by` で十分だと思うし、 [seedbank](https://github.com/james2m/seedbank) も `load` や `require` を使えば十分だから必要性を感じない。

[sprig](https://github.com/vigetlabs/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` の中身が次であるとする。

```ruby
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` が次の内容で存在するとする。

```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` メソッドを使う。

```ruby
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 メソッド

```ruby
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` が存在する。

```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"
```

これを処理するためのコードは次:

```ruby
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` の中を下記にする:

```ruby
# 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` なので、次のようにする必要がある。

```ruby
# 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 とかで教えてくれたら嬉しい。