CSVサーバープロジェクトの開始

解決したい課題

会社には月次決算がつきものだ。決算では、エクセルなどの表計算ソフトで各種資料を作成する。管理部門が作成する資料は、部・課・係・商品コード別等々の組織ごとに集計した一覧表の形式であることが多い。作成した資料は、各部署に配布しなければならない。しかし、今時の会社である。以前のように、全社の合計が載った資料を全ての人に配布すると、上層部からクレームが付く。上層部からは、各役職の担当組織の分だけ配布するようにとの指示。しょうがないので、その指示通り配布しようとするが、これが意外と大変で、毎月の大仕事になってしまう。
まず、全社の組織別の数字が表示された一覧表から、配布する単位の組織の分だけコピーして、別ファイルを作成する。それを各部署の担当者宛にメールの添付ファイルとして送信する。文章で書くとこれだけだが、以下のような大変さが毎月ずっと続くのだ...。

  • 配布先の組織が20あれば、20ファイルに分ける必要がある。
  • 宛先と添付するファイルを間違えないように、十分な注意が必要だ。
  • 人事異動は頻繁にあり、メールの宛先の管理にも手間がかかる。
  • 資料は1つだけではない。配布する資料の数だけ、上記の作業負担が増えることになる。
  • 資料に訂正があれば、再送することもある。

そこで、Railsの出番である。配布する資料をCSVファイルとして書き出し、それをCSVサーバーにアップロードする。各ユーザーはログインすると、自分の担当組織の部分だけ、各種資料を閲覧することが出来る。そんなwebアプリケーションを作ってみたい。csv_serverプロジェクトの始まり。果たして望む通りのものが出来るかどうか...。

      • 多くの会社では、上記のような資料配布を、どのように対応しているのだろうか?苦労最小限の方法が知りたい...。それとも大した苦労じゃない?


CSVファイルをアップロードして、閲覧する。

前回まで、CSVファイルを取り扱うクラスを作成して、試行錯誤してみたが、やはりRailsで開発するなら、データベースに取り込んでしまった方が良いと感じた。その方が、手間もかからないし、高機能なActiveRecordがいろいろなことを助けてくれる。大まかな処理の流れは以下のようにする予定。まだ、ユーザーごとの閲覧制限はない。

  • CSVファイルを選択して、アップロードする。
  • ファイル名と同じテーブルを作成して、そこにCSVファイルの内容を書き込む。
  • テーブルの内容は、ajax_scaffoldを利用してアクセスする。

CSVファイルを管理するCSVモデル、ビュー、コントローラー

csv_serverプロジェクトの作成
rails csv_server --database=sqlite3
モデルの作成

csvモデルには、以下の機能を追加する予定。

  • アップロードしたファイルを元に、データベースにファイル名と同じテーブルを追加する。
  • 追加したテーブルに、CSVファイルの内容をインポートする。
script/generate model csv
csvsテーブルを作成
マイグレート
db/migrate/001_create_csvs.rb
class CreateCsvs < ActiveRecord::Migration
  def self.up
    create_table :csvs do |t|
      t.column :file_name,        :string
      t.column :file_comment,     :string
      t.column :file_size,        :integer
      t.column :created_at,       :datetime
      t.column :updated_at,       :datetime
    end
  end

  def self.down
    drop_table :csvs
  end
end
scaffoldの実行
script/generate scaffold csv
csvモデルをコーディング
モデル
app/models/csv.rb
  • CSVファイルをアップロードした時に、テーブルを追加して、データベースにインポートする処理を行う。
class Csv < ActiveRecord::Base
  # ファイル名がテーブル名になるので、重複チェックを行う。
  validates_uniqueness_of :file_name
  # アップロードするファイルを指定しないと、エラーにする。
  def validate
    errors.add(:file_name, "アップロードするファイルを指定してください。") if @file.nil? || @file.size == 0
  end
  
  # Tempfileオブジェクトをインスタンス変数に保存する。
  # new()や、update_attributes()の時、呼び出される。
  def tempfile=(file)
    return if file.nil? || file.size == 0
    @file = file
    self.file_name = file.original_filename
    self.file_size = file.size
    # file.read.sizeとやってしまうと、29行目で@file.readした時、なぜかnilが返る。
    # 1回アクセスすると、そのデータは消えてしまう?
  end
  
  # テーブルを追加する。
  def create_csv_table
    execute_sql("create table #{table_name} (#{csv_columns} PRIMARY KEY (id));")
  end
  
  # ファイル名の拡張子の手前までをテーブル名として返す。
  def table_name
    File.basename(self.file_name, ".*")
  end
  
  # CSVファイルの1行目をフィールド名として返す。
  # 処理の過程で、以下3つの処理も同時に実行する。
  #   - 文字コードの変換や余分な空白や特殊文字を整形する処理。
  #   - idフィールドの追加処理。
  #   - データ部分をpublic/temp.csvファイルとして保存する処理。
  def csv_columns
    # 「"」を取り除いて、文字コードをUTF-8に変換して、改行で区切った配列を返す。
    str   = @file.read.gsub(/"+/, '')
    str   = NKF.nkf('-w', str)
    lines = str.split("\n")
    # 配列の先頭を取り出して、カンマで区切った配列にして、余分な空白を削除する。
    columns = lines.shift.split(",").collect{|n| n.strip}
    
    # id列が存在しなければ追加する。
    if columns.index("id").nil?
      columns.unshift("id")
      id = 0
      lines.collect! {|line| id +=1; "#{id},#{line}"}
    end
    
    # インポート用のファイルを保存する。
    File.delete("public/temp.csv") rescue nil
    File.open("public/temp.csv", "wb") do |f|
      f.write(lines.join("\n"))
    end
    
    # フィールド名 string, ... の書式で返す。
    columns.inject("") do |result, column|
      result << "#{column} string, "
    end
  end
  
  # csvデータをインポートする。
  # インポートを高速化するため、sqlite3のインポート命令を、直接コマンド実行する。
  def import_csv
    env = ENV['RAILS_ENV'] || 'development'
    system("sqlite3", "-separator", ",", "db/#{env}.sqlite3", ".import public/temp.csv #{table_name}")
  end
  
  # テーブルを削除する。
  def drop_csv_table
    execute_sql("drop table #{table_name}")
  end
  
  # テーブルの追加、削除のSQLを実行する。
  def execute_sql(sql)
    env = ENV['RAILS_ENV'] || 'development'
    db = SQLite3::Database.new("db/#{env}.sqlite3")
    db.execute(sql)
    db.close    
  end
end
csvsコントローラーの修正
コントローラー
app/controllers/csvs_controller.rb
class CsvsController < ApplicationController
...(途中省略)...
  def create
    @csv = Csv.new(params[:csv])
    if @csv.save
      @csv.create_csv_table
      @csv.import_csv
      flash[:notice] = 'Csv was successfully created.'
      redirect_to :action => 'list'
    else
      render :action => 'new'
    end
  end
...(途中省略)...
  def destroy
    @csv = Csv.find(params[:id]).destroy
    @csv.drop_csv_table
    redirect_to :action => 'list'
  end
...(途中省略)...
end
      • オレンジ色の部分を追記した。
csvビューの修正
新規アップロード
app/views/csvs/new.rhtml
  • start_form_tagに、:multipart => trueを追加した。ファイルをアップロードするためのおまじない。忘れるとアップロードできない。
<h1>New csv</h1>

<%= start_form_tag({:action => 'create'}, :multipart => true) %>
  <%= render :partial => 'form' %>
  <%= submit_tag "Create" %>
<%= end_form_tag %>

<%= link_to 'Back', :action => 'list' %>
入力フォーム
app/views/csvs/_form.rhtml
<%= error_messages_for 'csv' %>

<!--[form:csv]-->
<p><label for="csv_tempfile">File</label><br/>
<%= file_field 'csv', 'tempfile'  %></p>

<p><label for="csv_file_comment">File comment</label><br/>
<%= text_field 'csv', 'file_comment'  %></p>
<!--[eoform:csv]-->
アップロードしたCSVファイルのリスト表示
app/views/csvs/list.rhtml
<h1>Listing csvs</h1>

<table>
  <tr>
  <% for column in Csv.content_columns %>
    <th><%= column.human_name %></th>
  <% end %>
  </tr>

  <%= render :partial => 'listd', :collection => @csvs %>
</table>

<%= link_to 'Previous page', { :page => @csv_pages.current.previous } if @csv_pages.current.previous %>
<%= link_to 'Next page', { :page => @csv_pages.current.next } if @csv_pages.current.next %> 
<br />
<%= link_to 'New csv', :action => 'new' %>
リスト表示のデータ部分を描画
app/views/csvs/_listd.rhtml
  • ファイル名のリンクは、displaysコントローラーのlistメソッドを呼び出している。
  • この時、パラメーターとしてテーブル名も送信している。
  • displaysコントローラーでは、このテーブル名を見て、参照するテーブルを切り替えるようにした。
  • "12345678".gsub(/(\d)(?=(\d\d\d)+(?!\d))/, '\1,')を実行すると、"12,345,678"が返ってくる。
  • つまり、文字列から数字を発見して、3桁区切りでカンマを入れてくれる正規表現だ。(ちゃんと理解できていないが...。)
  • 参考ページ:Rubyのある風景 - Regexp Lookahead
<!--[:]-->
<tr>  
  <td><%= link_to h(listd.file_name), :controller => 'displays', :action=>'list', :table => listd.table_name %></td>
  <td><%=h listd.file_comment %></td>
  <td></td>
  <td align="right"><%=h listd.file_size.to_s.gsub(/(\d)(?=(\d\d\d)+(?!\d))/, '\1,') %> B</td>
  <td><%=h listd.created_at.strftime('%Y-%m-%d %H:%M:%S')  %></td>
  <td><%=h listd.updated_at.strftime('%Y-%m-%d %H:%M:%S')  %></td>

  <td align="center"><%= link_to 'Show', :action => 'show', :id => listd %></td>
  <td align="center"><%= link_to 'Edit', :action => 'edit', :id => listd %></td>
  <td align="center"><%= link_to 'Destroy', { :action => 'destroy', :id => listd }, :confirm => 'Are you sure?', :post => true %></td>
</tr>
<!--[:]-->

アップロードされた内容を閲覧するDisplayモデル、ビュー、コントローラー

モデルの作成

displayモデルはに、以下の機能を追加する予定。

  • アップロードされた内容を保持しているテーブルを、ajax_scaffoldをベースに参照する。
  • Displayモデル1つで、複数のテーブルを切り替えて、内容を表示する。
script/generate model display
displaysテーブルを作成
マイグレート
db/migrate/002_create_displays.rb
  • これから追加されるテーブルを参照するので、ここで追加するテーブルは不要だが、この後のscaffoldを実行するため、便宜的に追加しておいた。
class CreateDisplays < ActiveRecord::Migration
  def self.up
    create_table :displays do |t|
      t.column :name, :string
    end
  end

  def self.down
    drop_table :displays
  end
end
ajax_scaffoldの実行
  • まだ、ajax_scaffold_generatorをインストールしていない場合は、以下を実行しておく。
gem install ajax_scaffold_generator 
  • あとは通常のscaffoldと同じように実行した。
script/generate ajax_scaffold display
displaysコントローラーの修正
コントローラー
app/controllers/displays_controller.rb
  • 参照するテーブルを切り替える処理を追加した。
  • 1ページの表示件数を50件にした。
class DisplaysController < ApplicationController
  include AjaxScaffold::Controller

  # 全ての処理に先立って、select_tableメソッドで参照するテーブルを選択する。
  before_filter :select_table
  
  after_filter :clear_flashes
  before_filter :update_params_filter

  # 1ページの表示件数を設定
  def default_per_page
    50
  end

  # 渡されたtableパラメーターを、テーブル名としてセットすれば、複数のテーブルを切り替えて参照できる。
  def select_table
    Display.set_table_name params[:table] || 'displays'    
  end
...(以下省略)...
      • オレンジ色の部分を追記した。


以上で、CSVファイルをアップロードして、その内容を表示できるようになった。まだ、ajax_scaffold側の機能は完全に利用できないが、今後修正していく予定。