3行まとめ
- 2つのCSVを行・列単位で比較して差分をハイライトするブラウザ完結ツールを作った。テキスト差分でなく表として突き合わせるのが肝
- 行のマッチングは2モード。並び順で突き合わせる「インデックス方式」と、キー列の値で突き合わせる「キー方式」。キー方式は
Map+FIFOキューのバッグマッチングで重複キーも順に対応づける - 列構成が違うCSVは列のユニオンを取って比較し、片方にしかない列は
null(=欠落)として空値と区別する
CSVの新旧を比べたい——商品マスタの更新前後、会員名簿のインポート前後、月次データのバージョン差分。テキストとして diff にかけると、行の順番が1つズレただけで全行が差分扱いになって使い物にならない。CSVは行×列の表なので、表として突き合わせないと意味のある差分にならない。
ぱんだツールズのCSV差分比較は、2つのCSVをキー列で突き合わせて、行の追加・削除・変更をセル単位でハイライトするツール。処理はすべてブラウザ内で完結し、CSVの内容はサーバーに送信されない。顧客データや会計データのような機密性の高いCSVも安全に比較できる。
この記事では、行のマッチングをどう実装したか(インデックス方式とキー方式)、重複キーを扱うバッグマッチング、列構成の違うCSVを比べる列ユニオン、そしてセル単位の差分検出を、実装ベースで解説する。
行のマッチングは2モード
差分の本質は「Before のどの行と After のどの行が同じ行か」を決めること。ここが決まればセルを比べるだけ。このツールは2つの方式を用意している。
- インデックス方式(キー列なし):行の並び順(位置)で突き合わせる。1行目と1行目、2行目と2行目…
- キー方式(キー列あり):会員IDなどの列の値で突き合わせる。並び順が違っても同じキーの行同士を比べる
インデックス方式は単純に位置で zip して、足りない側は null で埋める。
function matchRowsByIndex(leftHeaders, leftRows, rightHeaders, rightRows, pushRow) {
const maxLen = Math.max(leftRows.length, rightRows.length)
for (let i = 0; i < maxLen; i++) {
const leftRow = leftRows[i]
const rightRow = rightRows[i]
pushRow(
leftRow ? toRecord(leftHeaders, leftRow) : null,
rightRow ? toRecord(rightHeaders, rightRow) : null,
)
}
}
位置合わせなので、行を1つ挿入すると以降が全部ズレて「変更」だらけになる。並び順が安定しているデータ向け。行を一意に識別できる列があるなら、次のキー方式の方が正確。
キー方式は Map+FIFOキューのバッグマッチング
キー方式は、まず After 側をキー列の値でインデックス化する。ここでポイントなのが、キーごとに1件でなく「キュー(配列)」で持つこと。同じキーの行が複数あっても取りこぼさないようにするため。
function matchRowsByKey(leftHeaders, leftRows, rightHeaders, rightRows, keyColumn, pushRow) {
const rightQueues = new Map<string, CsvRecord[]>()
rightRows.forEach((row) => {
const record = toRecord(rightHeaders, row)
const key = record[keyColumn]
const queue = rightQueues.get(key)
if (queue) queue.push(record)
else rightQueues.set(key, [record])
})
leftRows.forEach((row) => {
const leftRecord = toRecord(leftHeaders, row)
const key = leftRecord[keyColumn]
const queue = rightQueues.get(key)
const rightRecord = queue && queue.length > 0 ? queue.shift()! : null // 先頭から取り出す
pushRow(leftRecord, rightRecord)
})
// 左とマッチしなかった右の行 = 追加された行
for (const queue of rightQueues.values()) {
for (const record of queue) {
pushRow(null, record)
}
}
}
左の各行について、同じキーの右行をキューの先頭から shift() で取り出してペアにする。キーがなければ null(=削除された行)。そして最後にキューに残った右行は、左に相手がいなかった行なので「追加」として出す。
Map+キューにしているのは、キーが一意でない現実のCSVに対応するため。同じ会員IDが2行ある、というデータはよくある。1キー1件の Map にすると後勝ちで前の行が消えるが、キューにしておけば「左の1件目 ↔ 右の1件目」「左の2件目 ↔ 右の2件目」と多重集合(バッグ)として順に対応づく。FIFO(shift)なので、同じキーの中では出現順で素直にペアになる。集合の要素に重複を許すマッチングを、キューの消化で表現している。
列構成が違うCSVを比べる — 列のユニオン
BeforeとAfterで列(ヘッダー)が違うこともある。列が増えた・減った・並びが変わった。まず両者の列の**ユニオン(和集合)**を取り、比較の土台にする。
function buildUnionHeaders(left: string[], right: string[]): string[] {
const union = [...left]
for (const header of right) {
if (!union.includes(header)) union.push(header)
}
return union
}
左の列を並び順のまま採り、右にしかない列を後ろに足す。この統合ヘッダーで両CSVを揃えて表示する。
ここで大事なのが、「その列がそもそも無い」と「値が空文字」を区別すること。統合ヘッダーの各列について、片方のCSVに元々その列が無ければ null、あれば値(無い値は空文字)を入れる。
function recordToCells(record, headers, presentHeaders): (string | null)[] | null {
if (record === null) return null
return headers.map((header) =>
presentHeaders.includes(header) ? record[header] ?? '' : null // 列が無い→null、値が空→''
)
}
null は「この行のこの列は存在しない」を意味し、UIでは「−」で表示する。空文字 '' は「列はあるが値が空」。この2つを潰すと、列が消えたのか値が消えたのかが区別できなくなる。null を明示的に持つことで、列構成の変化も差分として正しく扱える。
セル単位の差分と行の種別判定
行のペアが決まったら、統合ヘッダーの各列でセルを比べる。比較は素朴な !== だが、null(列欠落)も1つの値として比べるのがミソ。
function compareCells(left, right): boolean[] {
const length = Math.max(left?.length ?? 0, right?.length ?? 0)
const result: boolean[] = []
for (let i = 0; i < length; i++) {
result.push((left ? left[i] : null) !== (right ? right[i] : null))
}
return result
}
これで列ごとに「変わったか」の真偽配列(changedColumns)が得られる。行の種別はこう決まる。
- 左が
null→ 追加(Afterにしかない行) - 右が
null→ 削除(Beforeにしかない行) -
changedColumnsに1つでも true → 変更 - どれでもない → 変更なし
変更行では changedColumns[i] が true のセルだけ背景を濃くして、行のどのセルが変わったかをピンポイントで示す。テキスト差分の「行まるごと変わった」ではなく、表として「この行のこの列が変わった」まで分かるのが、CSVを表として扱う利点。
差分レポートは「状態」列付きで書き出す
差分結果はCSVレポートとしてダウンロードできる。先頭に「状態」列を足し、変更行は変更前・変更後の2行に分けて出す。
export function buildCsvDiffReport(result: CsvDiffResult): string {
const lines: string[] = [['状態', ...result.headers].map(escapeCsvField).join(',')]
for (const row of result.rows) {
if (row.type === 'equal') {
lines.push(['変更なし', ...(row.rightCells ?? []).map((c) => c ?? '')].map(escapeCsvField).join(','))
} else if (row.type === 'added') {
lines.push(['追加', ...].map(escapeCsvField).join(','))
} else if (row.type === 'removed') {
lines.push(['削除', ...].map(escapeCsvField).join(','))
} else {
lines.push(['変更前', ...(row.leftCells ?? []).map((c) => c ?? '')].map(escapeCsvField).join(','))
lines.push(['変更後', ...(row.rightCells ?? []).map((c) => c ?? '')].map(escapeCsvField).join(','))
}
}
return lines.join('\n')
}
セルの null は書き出し時に空文字へ落とし、カンマ・ダブルクォート・改行を含むセルはRFC 4180に従ってクォートする。差分を目で見るだけでなく、Excelに取り込んで「状態」列でフィルタする、といった二次利用ができる。
パースまわりの前提
CSVのパースは、テキスト全体を1文字ずつ読み進める状態機械(state machine)の自前パーサーを使っている。ダブルクォートで囲まれたフィールド内のカンマや "" によるクォートのエスケープに加え、クォートで囲まれたフィールド内に埋め込まれた改行(CRLFも含む)も1つのフィールドとして正しく読める。改行がフィールド区切りなのかクォート内の文字なのかは inQuote フラグで判定しているので、"東京都\n港区" のようにセル内に改行を持つデータも崩れない。
また、ファイル読み込みは file.text()(UTF-8デコード)なので、Excel由来の Shift_JIS CSVはそのままだと文字化けする。先にCSV文字コード変換でUTF-8にしてから比較すると正しく突き合わせられる。キー列のドロップダウンは両CSVに共通する列だけを候補に出し、入力を差し替えてキー列が消えたら選択を自動でリセットする、といったUI側の整合も取っている。
まとめ
- CSV差分はテキスト差分でなく「表として行を突き合わせる」問題。行のマッチングが決まればセル比較は素直
- 行マッチングはインデックス方式(位置)とキー方式(値)の2つ。キー方式は
Map+FIFOキューのバッグマッチングで、重複キーも出現順に対応づき、余った右行は「追加」になる - 列構成が違うCSVは列のユニオンで土台を作り、片方に無い列は
nullとして空文字と区別する。列が消えたのか値が消えたのかを保つ - セル比較は
nullも含めた!==。列ごとの真偽配列で、行の種別判定とセル単位ハイライトの両方をまかなう - パースは文字単位の状態機械で、クォート内の埋め込み改行も含めて正しく読める。入力はUTF-8前提
マスタ更新や名簿インポートの前後比較にどうぞ。CSVはブラウザの外に出ない。
ぱんだツールズ では他にも CSV文字コード変換・銀行明細CSV変換・CSV↔JSON変換・PDF処理など、日本語の実務ファイルに強いブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。