目次
Rails で 先般 クライアント から 基本のデータを Excel で入れられるようにしてほしい という依頼がありました。 編集を行うデータだったら編集画面を作って DB で管理したほうがいいことを伝えましたが、 どうしても Excel がいいそうで。 Excel は必要以上に多機能で、すべてのデータをそのままデータベースに反映するのが難しそうであることから止めておいた方がいいと伝えて、 Excel との インポート・エクスポート が容易な TSV で対応することとなりました。 そのときの方法を書いておきます。
TSV は 内容をコピーしてそのまま表計算ソフトに貼り付けることもできますし、表計算ソフトから TSV に貼り付けることもできるので、 Excel でも扱いやすいです。 データが欠損することがあるので、その点で注意が必要です。
環境
- Ubuntu 14.04 LTS
- Rails 4.1.8
- Ruby 2.2.2
方針
- TSV 取り込みの機能を task で作り、
rake
コマンド で取りこめるようにする。 rake db:seed
を行ったときに、 TSV を読み込むタスクを実行するようにする。
使い方
説明
rake seed_file:load TSV=aaa,bbb,ccc/ddd
で aaa.tsv
、 bbb.tsv
、 ccc/ddd.tsv
をこの順で取り込みます。 特別処理をするコードを書いていない場合は クラス Aaa
、Bbb
、Ccc::Ddd
が必要になります。
rake seed_file:load
を実行しても db/seeds/tsv
の下のすべての TSV を取り込むようにはなっていません。 進行中だったプロジェクトにおいて必要性を感じなかったので実装しませんでした。
特別処理とは 下に記述してある category_table.tsv
を扱う場合のようなコードをいいます。
注意点
- TSV は
db/seeds/tsv
ディレクトリ の下に モデル名のスネークケース + “.tsv” の形で配置されるものとします。 サブディレクトリ内に配置することも可能です。 - TSV は 1行目をカラム名、2行目以降を値にします。 存在しないカラムが記述されている場合、そのカラムはデータベースに取り込まれません。
_memo
という存在しない名前のカラムを作って、備考として利用することも可能です。 - 一度取り込んだ id は変更しないこと。 id をもとに データ の新規作成・更新を行っているので、 id が変更されると意図せぬデータができます。
- 取り込んだデータの削除はできません。
プログラム
TSV
オブジェクト を作成し、 1行ずつ読み込みます。 一度にすべてをメモリに読み込まないので、大きなファイルでも対応可能です。 ruby では require 'csv'
とすることで TSV も扱える CSV クラス が使えるようになるのですが、 カラムと値のハッシュを取得したかったり、 TSV の処理をメイン処理内に書きたくなかったりで、 TSV クラス を作る運びとなりました。
コード
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 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
namespace :seed_file do # class to handle TSV file class TSV TSV_ROOT = Rails.root.join('db', 'seeds', 'tsv') # Initialize. # first column of tsv file should be column name array # ==== Parameters # * +file_name+ - tsv file name except extension def initialize(file_name) @file_path = File.join(TSV_ROOT, file_name) end # Yield each line as hash. # ==== Example # instance.each{|values| # value = values['column_name'] # } # ==== Note # This method is usefull when handle too big tsv file, # because this method doesn't load all tsv contents into memory, # handle each line. def each(&block) File.open(@file_path) do |tsv| columns = self.class.split_line(tsv.readline) while !tsv.eof values = self.class.split_line(tsv.readline) kv = {} columns.each_with_index{|column_name, index| kv[column_name] = values[index] } yield kv end end end private # Split tab separated value string into array # ==== Parameter # * +line+ - tab separated value string def self.split_line(line) return line.chomp.split("t") end end # Load tsv file without transaction. # ==== Parameter # * +file_key_name+ - file key name, it can be category_table or table name. def load_tsv(file_key_name) tsv = TSV.new(file_key_name + '.tsv') case file_key_name when 'category_table' load_category_table_tsv(tsv) else load_table_tsv(tsv, file_key_name) end end # Load tsv file with transaction # ==== Parameter # * +file_key_name+ def load_tsv_aspect(file_key_name) ActiveRecord::Base.transaction do load_tsv(file_key_name) end end def load_category_table_tsv(tsv) category_type = nil tsv.each{|values| if category_type.nil? || category_type.id != values['type_id'].to_i # Update CategoryType category_type = CategoryType.find_or_initialize_by(id: values['type_id']) category_type.attributes = { name: values['type_name'], } category_type.save! end # Update Category category = Category.find_or_initialize_by(id: values['id']) category.attributes = { name: values['name'], type_id: category_type.id, } category.save! 1.upto(3) {|part| CategoryPart = CategoryPart.find_or_initialize_by( id: values['part_' << part.to_s << '_id']) value = values['part_' << part.to_s << '_value'] category_part.attributes = { part_number: part, category_id: category.id, detail: values['part_' << part.to_s], } category_part.save! } } end # load tsv to table # ==== Parameter # * +tsv+ - tsv object # * +file_key_name+ def load_table_tsv(tsv, file_key_name) model_class = file_key_name.classify.constantize tsv.each{|values| model = model_class.find_or_initialize_by(id: values['id']) values.each{|key, value| if model.has_attribute?(key) model[key] = value end } model.save! } end desc "load seed file" task load: :environment do if ENV.has_key?('TSV') tsv_names = ENV['TSV'].split(',') tsv_names.each{|tsv_name| load_tsv_aspect(tsv_name) } end end end |
説明
rake seed_file:load
が実行されると、 task load:
と書いてある行から実行されます。 :environment
は モデルクラスを使用するために必要です。 そこでは 環境変数 TSV
に渡された文字列をカンマで区切り、 load_tsv_aspect
を実行して データベースにインポートします。
load_tsv_aspect
は load_tsv
をトランザクションで囲んだものです。load_tsv
では 環境変数 TSV
に渡されていた名前に応じて処理を分けます。 基本的には 1つのTSVを1つのテーブルにロードするだけです。 しかし、 特別に表形式のほうがデータが管理しやすい場合で、そのほうがミスが少ない場合は 1つのTSVに3つのテーブルのデータを保存して管理します。 そういうときのために、 特殊なテーブルには専用の取り込みメソッドを使用します。 上のコードでは category_table
というのが 環境変数 TSV
に渡された場合に、 category_table.tsv
から 3つのテーブルにデータをインポートします。
一般的な場合の load_table_tsv
では、 カラムと値をチェックして、 指定されたカラムがテーブルにあればデータとして扱います。 TSVに記述されたカラムがなければ処理を行わないため、 _memo
といったカラムを作って、管理のための備考を追加することもできます。
ここまででタスクは完成しました。 ここからは seeds.rb
の説明をします。
seeds.rb
rake db:seed
を行った際に TSV を読み込むようにするには、 seeds.rb
に記述を加える必要があります。 追加するのは次の2行です。
1 2 |
ENV['TSV'] = 'category_table,prefecture' Rake::Task['seed_file:load'].invoke |
ENV['TSV']
で 取り込み対象の TSV を指定します。 そして Rake::Task['seed_file:load'].invoke
でタスクを実行して TSV を取り込みます。