Ruby: CSVでヘッダとボディを同時に定義するやり方

こんにちは @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の列に変更が入ります。
この実装例の場合、 headerrow の両方を変更する必要があります。

列数が少なければ目視で確認できますが、列数が増えてくると取り違えする可能性が高くなります。
例えば私ならこういう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より

おたより発掘

関連記事

週刊Railsウォッチ(20190617-1/2前編)マルチプルデータベースガイドが追加、mmcと「Ruby 3の型解析に向けた計画」、Ruby 2.6のCSVライブラリはいいほか

ExcelでCSV保存したときに半角スペースがはてな(?)に文字化けする

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

kazz

Javaエンジニアで生きてきましたがこのたびRailsエンジニアになれました。 オブジェクト指向中毒者のおっさんです。

kazzの書いた記事

夏のTechRachoフェア2019

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ