CSVファイルを置くだけでマスタデータを投入するメタプログラミング

## はじめに

[seed-fu](https://github.com/mbleigh/seed-fu) gemを使えば、Railsアプリケーションの初期データをうまく扱うことが出来る。
`db/fixtures`ディレクトリ以下にSeedデータ投入用スクリプトを入れて、`rails db:seed_fu`を実行すれば、モデルごとに初期データを投入してくれる。

しかし、モデルの数が増えてくると、`db/fixtures`以下がごちゃごちゃになってくるという欠点がある。

そこで今回は、seed-fuとメタプログラミングの技術を利用して、所定のディレクトリにCSVファイルを置くだけでRubyのコードの変更をすることなくマスタデータを追加できる仕組みを作ってみる。


## TL;DR

- `db/fixtures/master_data/`以下にマスタデータ用CSVファイルを配置
- ファイル名は、モデル名の複数形（`Item`モデルなら、`items.csv`とする）
- CSVファイルのヘッダ名と、　モデルの属性名を一致させること
- マスタデータ投入用スクリプトは以下の通り

```db/fixtures/masters.rb
require 'csv'

data_dir = "#{Rails.root}/db/fixtures/master_data"
full_pattern = File.join(data_dir, '*.csv')

Dir.glob(full_pattern).each do |path|
  filename = File.basename(path, '.csv')
  klass = filename.capitalize.singularize
  model = Rails.const_get(klass)

  csv = CSV.read(path, headers: true)
  csv.each do |row|
    model.seed_once(:id) do |m|
      csv.headers.each { |h| eval("m.#{h} = #{row[h].inspect}") }
    end
  end
end
```


## サンプルコードと開発環境

サンプルコードはここに置いてある。

https://github.com/mktakuya/seed-fu-master-data-sample

環境は以下の通り。

```
$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.13.3
BuildVersion:	17D47

$ ruby -v
ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-darwin16]

$ rails -v
Rails 5.1.4

$ bundle info seed-fu
  * seed-fu (2.3.7)
	Summary: Easily manage seed data in your Active Record application
	Homepage: http://github.com/mbleigh/seed-fu
	Path: /Users/mktakuya/.rbenv/versions/2.4.2/lib/ruby/gems/2.4.0/gems/seed-fu-2.3.7

```


## プロジェクト作成

```
$ rails new seed-fu-master-data-sample -T
$ cd seed-fu-master-data-sample
```


## データベースの作成とmigrate、サーバ起動

```
$ rails db:create db:migrate
$ rails s
# localhost:3000をブラウザで開いて、
# "Yay! You’re on Rails!" の表示を確認
```


## サンプル用のモデルの作成

適当にマスタデータとして扱いそうなモデルを作る。

用意するモデルは本当になんでもいいんだけどとりあえず、

* 都道府県 `Prefecture`
* JR北海道 千歳線の駅 `Station`
* 全国の国立高専 `Kosen`

あたりにしておこう。

```
$ rails g model Prefecture name:string name_kana:string code:integer
$ rails g model Station name:string name_kana:string number:string
$ rails g model Kosen name:string name_kana:string
```

```
$ rails db:migrate
```


## Seed-Fuのインストール

`Gemfile`に以下行を追記

```ruby
gem 'seed-fu', '~> 2.3'
```

`bundle install` 実行

```
$ bundle install
```


## CSVファイルの準備

CSVファイルを格納する`db/fixtures/master_data`ディレクトリの作成

```
$ mkdir -p db/fixtures/master_data
```

適当にCSVファイルを作って配置。今回はこんな感じに。

```db/fixtures/master_data/prefectures.csv
name,name_kana,code
北海道,ほっかいどう,1
青森県,あおもりけん,2
岩手県,いわてけん,3
宮城県,みやぎけん,4
秋田県,あきたけん,5
山形県,やまがたけん,6
福島県,ふくしまけん,7
茨城県,いばらきけん,8
栃木県,とちぎけん,9
群馬県,ぐんまけん,10
埼玉県,さいたまけん,11
千葉県,ちばけん,12
東京都,とうきょうと,13
神奈川県,かながわけん,14
新潟県,にいがたけん,15
富山県,とやまけん,16
石川県,いしかわけん,17
福井県,ふくいけん,18
山梨県,やまなしけん,19
長野県,ながのけん,20
岐阜県,ぎふけん,21
静岡県,しずおかけん,22
愛知県,あいちけん,23
三重県,みえけん,24
滋賀県,しがけん,25
京都府,きょうとふ,26
大阪府,おおさかふ,27
兵庫県,ひょうごけん,28
奈良県,ならけん,29
和歌山県,わかやまけん,30
鳥取県,とっとりけん,31
島根県,しまねけん,32
岡山県,おかやまけん,33
広島県,ひろしまけん,34
山口県,やまぐちけん,35
徳島県,とくしまけん,36
香川県,かがわけん,37
愛媛県,えひめけん,38
高知県,こうちけん,39
福岡県,ふくおかけん,40
佐賀県,さがけん,41
長崎県,ながさきけん,42
熊本県,くまもとけん,43
大分県,おおいたけん,44
宮崎県,みやざきけん,45
鹿児島県,かごしまけん,46
沖縄県,おきなわけん,47
```

```db/fixtures/master_data/stations.csv
name,name_kana,number
苫小牧,とまこまい,H18
沼ノ端,ぬまのはた,H17
植苗,うえなえ,H16
新千歳空港,しんちとせくうこう,AP15
南千歳,みなみちとせ,H14
千歳,ちとせ,H13
長都,おさつ,H12
サッポロビール庭園,さっぽろびーるていえん,H11
恵庭,えにわ,H10
恵み野,めぐみの,H09
島松,しままつ,H08
北広島,きたひろしま,H07
上野幌,かみのっぽろ,H06
新札幌,しんさっぽろ,H05
平和,へいわ,H04
白石,しろいし,H03
苗穂,なえぼ,H02
札幌,さっぽろ,01
```

```db/fixtures/master_data/kosens.csv
name,name_kana
明石,あかし
秋田,あきた
旭川,あさひかわ
阿南,あなん
有明,ありあけ
石川,いしかわ
一関,いちのせき
茨城,いばらき
宇部,うべ
大分,おおいた
大島商船,おおしましょうせん
沖縄,おきなわ
小山,こやま
香川,かがわ
鹿児島,かごしま
木更津,きさらづ
北九州,きたきゅうしゅう
岐阜,ぎふ
釧路,くしろ
熊本,くまもと
久留米,くるめ
呉,くれ
群馬,ぐんま
高知,こうち
佐世保,させぼ
鈴鹿,すずか
仙台,せんだい
津山,つやま
鶴岡,つるおか
東京,とうきょう
徳山,とくやま
鳥羽商船,とばしょうせん
苫小牧,とまこまい
富山,とやま
豊田,とよた
長岡,ながおか
長野,ながの
奈良,なら
新居浜,にいはま
沼津,ぬまづ
函館,はこだて
八戸,はちのへ
広島商船,ひろしましょうせん
福井,ふくい
福島,ふくしま
舞鶴,まいづる
松江,まつえ
都城,みやこのじょう
弓削商船,ゆげしょうせん
米子,よなご
和歌山,わかやま
```


## マスタデータ投入用スクリプト

ポイントは、こんな感じか。

- CSVのファイル名からconst_getを使ってモデルのクラスを呼び出す
- CSVのヘッダ名から動的にカラム名を生成し、evalで呼び出す

```db/fixtures/masters.rb
require 'csv'

data_dir = "#{Rails.root}/db/fixtures/master_data"
full_pattern = File.join(data_dir, '*.csv')

Dir.glob(full_pattern).each do |path|
  filename = File.basename(path, '.csv')
  klass = filename.capitalize.singularize
  model = Rails.const_get(klass)

  csv = CSV.read(path, headers: true)
  csv.each do |row|
    model.seed_once(:id) do |m|
      csv.headers.each { |h| eval("m.#{h} = #{row[h].inspect}") }
    end
  end
end
```


## シードデータ投入スクリプトの実行と確認

`rails db:seed_fu` 実行後、Railsコンソールでデータ投入に成功していることを確認。

```
$ rails db:seed_fu
```

```
$ rails c
Prefecture.count
#=> 47
Station.count
#=> 18
Kosen.count
#=> 51
```


## おわりに

これで、適切なCSVファイルを配置するだけでマスタデータ投入をいい感じにやってくれる仕組みができた。

これはどういう時に便利なのだろうか。エンジニアとオペレータの人がいて、オペレータがマスタデータを追加したい場合はCSVファイルを用意すればいいだけ、みたいな状況だと便利なのかな。