こんにちは @kazz です。長い梅雨が終わって暑い夏がやってきました。夏のTechRachoフェアということで、今日はみなさん1度は実装したことのあるCSV出力に関して語って見たいと思います。
前提
今回は以下のテーブルをCSVに出力したいとおもいます。
DB
class CreateArticles < ActiveRecord::Migration[5.2]
def change
create_table :articles do |t|
t.integer :year # 発行年
t.integer :no # 号数
t.string :title # タイトル
t.string :description # 内容
t.integer :price # 価格
t.timestamps
end
end
end
※ NOT NULLとかINDEXとかはご容赦ください
適当にデータを突っ込んだ画面です。
※新発売!はけんちゃんラーメン方式です
CSVの出力
はじめの要件
- 全レコードの「タイトル」と「内容」「価格」だけをCSVに出力したい
- ヘッダは日本語でつけてね
Articleのクラスメソッドで実装するならこんな実装になるかと思います。
class Article < ApplicationRecord
def self.csv_dump
header = %w[タイトル 内容 価格]
CSV.generate do |csv|
csv << header
all.each do |article|
row = []
row << article.title
row << article.description
row << article.price
csv << row
end
end
end
end
もちろん仕様変更します!
- 「内容」は全部
新発売!
じゃないか!いらない - 発行年度と号数を出したい
こんな感じで改修すると思います。
class Article < ApplicationRecord
def self.csv_dump
header = %w[年度 号数 タイトル 価格]
CSV.generate do |csv|
csv << header
all.each do |article|
row = [
article.year,
article.no,
article.title,
article.price
]
csv << row
end
end
end
end
もちろん仕様変更します!!
- じつは価格として税込価格(8%でいいよ)も追加して
- あぁ販売テーブルとJOINして販売数とかも追加して....
...😢
要求変更に関わる実装上の課題
このように仕様の変更が起こる度に、CSVの列に変更が入ります。
この実装例の場合、 header
と row
の両方を変更する必要があります。
列数が少なければ目視で確認できますが、列数が増えてくると取り違えする可能性が高くなります。
例えば私ならこういうCSVを作成してしまう可能性は120%自信があります(ドヤ
年度 | 号数 | タイトル | 販売数 | 価格 |
---|---|---|---|---|
2018 | 1 | 2018年01号 | 540 | 500 |
2018 | 2 | 2018年02号 | 540 | 482 |
2018 | 3 | 2018年03号 | 540 | 38 |
... |
※「販売数」と「価格」を取り違えた例。データ次第では気が付かない
コードを改善しよう
ヘッダと、ヘッダに対応する各値の実装が分離しているから取り違えてしまいます。
ならば同時に書ければ取り違えは起こりません。
こんなコードにしてみました。
def self.dump_csv
rule = {
'年度' => lambda(&:year),
'号数' => lambda(&:no),
'タイトル' => lambda(&:title),
'価格' => lambda(&:price),
}
CSV.generate do |csv|
csv << rule.keys
all.each do |article|
csv << rule.values.map { |predicate| predicate.call(article) }
end
end
end
なにしてるんだろう?
まずはヘッダの出力です。
csv << rule.keys
でヘッダ部の出力になります。
csv << ['年度', '号数', 'タイトル', '価格']
と全く同じです
各行の出力
all.each do |article|
row << rule.values.map { |predicate| predicate.call(article) }
csv << row
end
row
にはあるarticle
をCSVの1行に変換した配列が入っていそうですが、具体的に何をしているんでしょう?詳しく見てみましょう
rule.values.map { |predicate| predicate.call(article) }
の部分ですが、まず rule.values.map
を書き下すと以下のようになります。
[
lambda(&:year).call(article),
lambda(&:no).call(article),
lambda(&:title).call(article),
lambda(&:price).call(article)
]
例えば先頭の lambda(&:year).call(article)
を書き下してみましょう。
lambda(&:year).call(article)
lambda
を書き下すと以下の用になるので
->(arg) { arg.year }.call(article)
call(article)
が実行されると
article.year
となります。
つまり全体では、結局以下のようになります。
[
article.year,
article.no,
article.title,
article.price
]
まとめ
ヘッダ名とヘッダに対応する値の取得方法(述語)とを、予め対応づけしておくことで、列の変更に強い実装にすることができました。
列数が大量になるCSVを出力する羽目になった場合は是非お試しください
おまけ(Tips)
- Q: ヘッダ名が重複するので、hashで定義出来ません。どうすればいいですか
- A: Arrayでどうぞ
rule = [
[ '価格', lambda(&:price) ],
[ '価格', lambda(&:price_with_tax) ]
]
- Q: 税込価格とかモデルにいないんですが、どうすればいいですか
- A:
lambda(:&method)
の構文に拘ることはありませんよ
rule = {
'価格(税込)' => -> (article) { article.price * 1.08 }
}
- Q:
-> {}
でlambda定義するのは宗教上の理由でできません! - A: curryによる部分適用とかruby2.6以上なら関数合成を使ってみては?
rule = {
'価格(税込)' => lambda(&:price) >> lambda(&:*).curry(2).call(1.08)
}
社内Slackより
おたより発掘
すごい良さそう / “Ruby: CSVでヘッダとボディを同時に定義するやり方” https://t.co/sMMuXq4rN7
— ゆーじ / Yuji Imagawa (@ug23_) August 6, 2019
知見 good. けんちゃんラーメンって世代によっては分からないかも。。 "Ruby: CSVでヘッダとボディを同時に定義するやり方" https://t.co/DmsAxjPN6l
— Masao S (@masawo) July 31, 2019
関連記事
週刊Railsウォッチ(20190617-1/2前編)マルチプルデータベースガイドが追加、mmcと「Ruby 3の型解析に向けた計画」、Ruby 2.6のCSVライブラリはいいほか