こんにちは。学生アルバイトのohkiです。最近データを扱うということでは、生成したcsvファイルの差分を確認したいということがよくあります。テキストの差分を比較するツールは世の中たくさんありますが、それを用いてしまうと行方向の順番が異なるだけで差分となってしまいます。今回は行順を無視して特定のキーでcsvの差分を確認する方法を紹介したいと思います。

要件の整理

  • a. 特定のカラムの値が同一の行で比較する。
  • b. 比較対象の行が複数あった場合は、順不同の状態で一定の比較をする。
  • c. 比較対象の行が一方のCSVにしか存在しない場合も検出する。

ダメな例

要件を字面通り実装していくとa.の段階でこのようになると思います。

CSV.foreach(meibo_new.csv) do |new_row|
  CSV.foreach(meibo_old.csv) do |old_row|
    if new_row[key] == old_row[key]
    end
  end
end

数十行程度のCSVではこれでも問題ないかもしれませんが、1万行のcsv場合に1万×1万回の繰り返し処理となり、マシンによってはCPUリソースが足りなくなります。

探査はしてはいけない

今回どうしても課題となるのが、比較対象の行の引き当て部分の処理です。何も考えずに探査してしまうとリソース上の問題が生じてしまうので、前処理などの何かしらの工夫が必要となります。そして、そもそもとして探査をしてしまうと「c. 比較対象の行が、一方のCSVにしか存在しない場合も検出する。」の実装がより困難になります。存在しないものを探査することは出来ないからです。

成果物の解説

今回の成果物(本記事の末尾記載)は、以下のような処理の流れになっているので、これに沿って解説していきます。

  • ①(前処理)対象のcsvファイルを縦持ちテーブルに変換する。
  • ②(前処理)文字列ソートする。
  • ③上からポインタをずらしながら比較する。

前処理としてキーとするカラムで文字列ソートする

今回採用した探査せずに対象の行を比較する方法が、ソートして上から比較する、というものになります。ただ単純にソートして比較しても探査が必要になってしまうので、キーとしているカラムを先頭にしてからソートします。キーが先頭に来ていればどのような形式でも問題無いとは思いますが、今回は一行ずつ比較時の条件分岐の組みやすさから、縦持ちテーブルを採用しています。(横持ち、縦持ちのテーブル構造について詳しくはこちら

上からポインタをずらしながら比較する

上から順に一行ずつ比較してしまうと、 「c. 比較対象の行が、一方のCSVにしか存在しない場合も検出する。」の段階で行数のズレが生じてしまうため、行をずらす度に参照するポインタの調整が必要になります。差分がない場合やフィールド違いの場合は両者のcsvを一行ずつずらして問題ありませんが、キーに紐づく行数が異なったりキーが一方にしか存在しない場合は、両者のキーの値でソートした時により上にあるものだけのポインタを一行下にずらす必要があります。この実装より、探査をせずに、よりキーがズレている方のcsvの比較行をより正しい行に近づける、という処理になっています。

まとめ

今回は、csvファイルの特定キーで行順を無視して差分を見る方法について紹介しました。この手法であれば2つに限らず3つや4つのcsvの比較も一気に出来そうですね。コロナ禍の今、これからは健康とリソースの管理に気をつけてデータを確認していきましょう。

成果物

3,4行目と六行目の変数を適宜変更して実行してください。

require 'csv'
# csvファイル名
old_csv_name = "./meibo_old.csv"
new_csv_name = "./meibo_new.csv"
# 比較のキーとするカラム名
key = "name"
# csvの構造を縦持ちに変換
def puts_vertival_csv(csv_name, key, output_csv_io=STDOUT)
  CSV.foreach(csv_name, headers: true) do |csv_row|
    csv_row.each do |col, field|
      output_csv_io.puts( [ csv_row[key], col, field ].join(",") )
    end
  end
end
File.open("vertival_old.csv", 'w'){|csv_io| puts_vertival_csv(old_csv_name, key, csv_io)}
File.open("vertical_new.csv", 'w'){|csv_io| puts_vertival_csv(new_csv_name, key, csv_io)}
# 縦持ちcsvをソート
`sort vertival_old.csv > sorted_vertical_old.csv`
`sort vertical_new.csv > sorted_vertical_new.csv`
# 縦持ちcsvに対するファイルIOのハッシュ
io_hash = {'old' => File.open('sorted_vertical_old.csv'),'new' => File.open('sorted_vertical_new.csv')}
# 比較対象行のハッシュ
line_hash = {'new' => io_hash['new'].gets, 'old' => io_hash['old'].gets}
# 1行ずつ差分比較
result = []
while line_hash.values.any? do
  # 1行の情報をオブジェクト化
  csv_keys = ['key', 'col', 'field']
  new_line = [ csv_keys, (line_hash['new'] || csv_keys.join(',')).parse_csv ].transpose.to_h
  old_line = [ csv_keys, (line_hash['old'] || csv_keys.join(',')).parse_csv ].transpose.to_h
  new_old_line_h = {'new' => new_line, 'old' => old_line}
  # 行の差分ごとの分岐処理
  # 差分がない場合
  if new_line == old_line
    line_hash = {'new' => io_hash['new'].gets, 'old' => io_hash['old'].gets} # 両方のcsvの比較行を更新
  # フィールド違い
  elsif new_line['key'] == old_line['key'] && new_line['col'] == old_line['col']
    result << "diffrent field. key:#{new_line['key']}, col:#{new_line['col']}. #{old_line['field']} -> #{new_line['field']}"
    line_hash = {'new' => io_hash['new'].gets, 'old' => io_hash['old'].gets}
  # カラム名違い(キーに紐づく行数の違い)
  elsif new_line['key'] == old_line['key']
    lower_key, higher_key = line_hash.compact.sort_by{|k, v| v}.map{|k, v| k}  # ソート順が低い方のcsvの比較行を更新
    result << "#{lower_key} has more records. key:#{new_old_line_h[lower_key]['key']}"
    line_hash[lower_key] = io_hash[lower_key].gets
  # 異なるキー(一方で存在しないキー)
  else
    lower_key, higher_key = line_hash.compact.sort_by{|k, v| v.to_s}.map{|k, v| k}
    result << "#{lower_key} has more records. key:#{new_old_line_h[lower_key]['key']}"
    line_hash[lower_key] = io_hash[lower_key].gets
  end
end
puts result.uniq
Pocket