読者です 読者をやめる 読者になる 読者になる

CSVからモデルオブジェクトを生成する際に気をつけたこと

こんにちは、クックパッド編集室の加々美です。

現在、食や暮らしのトレンドを発信するメディアであるクックパッドニュースの開発に携わっています。

クックパッドニュースは、1週間に100本以上の記事を配信しています。
このように比較的多くの記事コンテンツを作成する際、記事の基本的なパラメータ(例えば配信時間や記事の執筆者)をWebアプリケーション上で一つ一つ設定して記事を作成するのは時に煩雑な作業になりがちで、特に編集スタッフにとっては、スプレッドシート上で記事のパラメータを設定できた方が分かりやすく、作業がより確実になる場合があるかと思います。
(また、スプレッドシートであれば楽に複数人で編集できるというメリットもあります)

今回は、スプレッドシートからエクスポートしたcsvを用いて、モデルオブジェクトを生成する際に気をつけたことを紹介します。
※ 本稿ではGoogle DriveのスプレッドシートからエクスポートしたCSVを用いることを前提としています。

例として、タイトル(string型)、執筆者(integer型)、公開日時(datetime型)といった属性を持つArticleオブジェクトを生成することを考えてみます。

以下のようなスプレッドシートから、適宜バリデーションなどを行いつつArticle(記事)モデルを生成することを目指します。 執筆者に関して、ArticleモデルはEditor(執筆者)モデルとの関連を持っているものとします。 f:id:fkagami:20160309202935p:plain

最低限のバリデーションは、スプレッドシート側で行う

例えば日時など、一定の書式で縛りたい値がある場合、Googleスプレッドシートはエクセル同様、入力された値の書式の検証をすることができます。 メニューから「データ → 検証」を選択し、検証したい書式を設定すれば、特定の行列で異常な値を入力できないようにしたり、警告を表示させることができます。 f:id:fkagami:20160309203002p:plain

これによって「2016//01/01」、「10;00」等といった、パースエラーを引き起こしうる不適切な値を入力することを事前に防ぐことができます。

ActiveModelをincludeして、Railに従う

データベースと紐付かないモデルであっても、モデル内でActiveModelをincludeすることで、通常のモデルと同様に、バリデーション等の機能を使うことが出来ます。

以下のCsvArticleモデルは、インポートしたCSVの1行1行が、CsvArticleモデルオブジェクトに対応するように実装しています。 このように実装することで、CsvArticleも通常のモデルと同じようにバリデーションを行いモデルにエラーを追加することで、View上で特定のCsvArticleに発生したエラーの確認をすることができるようになります。

※ 分かりやすくするために実装をある程度簡略化しています

csv_article.rb

require "csv"

class CsvArticle
  include ActiveModel::Model
  attr_accessor :title, :editor_name, :published_at

  # 通常のモデルと同じようにバリデーションを設定できる
  validates :title, presence: true
  validates :editor_name, presence: true
  validates :published_at, presence: true
  # editorsテーブルに、入力された執筆者に該当するものがあることをバリデーションする
  validate :validate_editor_exsistense

  class << self
    # CSVを元に生成された、CsvArticleの配列を作成
    def create_list_from_csv(file)
      csv_articles = []
      CSV.foreach(file.path, encoding: "UTF-8", headers: true, converters: :integer) do |row|
        values = row.to_h
        published_at = parse_date_time(values["公開日"], values["公開時間"])
        csv_articles << CsvArticle.new(
          title: values["タイトル"],
          editor_name: values["執筆者"],
          published_at: published_at,
        )
      end
      csv_articles
    end

    private

    def parse_date_time(date, time)
      Time.parse("#{date} #{time}")
    end
  end
  
  def convert_article
    Article.create!(
      title: title,
      editor: editor,
      published_at: published_at,
    )
  end

  private

  def editor
    return nil if editor_name.nil? || @editor_not_found
    @editor ||= begin
      # Editorモデルのname attributeが、editor_nameに合致するレコードを取得する
      editor = Editor.find_by(name: editor_name)
      @editor_not_found = true unless editor
      editor
    end
  end

  def validate_editor_exsistense
    errors.add(:editor_name, "#{editor_name}」が存在しません") if editor_name && editor.nil?
  end
end

csv_articles_controller.rb

class CsvArticlesController < ApplicationController

  def show
  end

  def create
    @csv_articles = CsvArticle.create_list_from_csv(params[:file])

    if @csv_articles.all?(&:valid?)
      @csv_articles.each do |csv_article|
        csv_article.convert_article
      end
      flash[:ok] = "#{@csv_articles.size}件、作成しました"
      redirect_to articles_path
    else
      render :show
    end
  end

show.html.haml

...
    - if @csv_articles && @csv_articles.any?(&:invalid?)
      -# 2 is csv's row start num
      - @csv_articles.each.with_index(2) do |csv_article, row_num|
        - if csv_article.invalid?
          - csv_article.errors.full_messages.each do |message|
            -# エラーの発生した行と、エラーメッセージの内容を表示
            .flash_message.error_message
              #{row_num}行目: #{message}

      %table
        %thead
          %tr
            %th タイトル
            %th 執筆者
            %th 公開日
        %tbody
          - @csv_articles.each do |csv_article|
            %tr
              -# view上でも以下のようにしてエラーのあったカラムを特定できる
              %td{ class: ("error" if csv_article.errors.key?(:title)) }
                = csv_article.title
              %td{ class: ("error" if csv_article.errors.key?(:editor_name)) }
                = csv_article.editor_name
              %td{ class: ("error" if csv_article.errors.key?(:published_at)) }
                = csv_article.published_at
...

このように、ActiveModelを活用すると、データベースと紐付かないオブジェクトも楽に扱うことができます。
以上、サンプルコードと共に、CSVからモデルオブジェクトを生成する例を紹介しました。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/