0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSV差分をブラウザで実装する — キー列でのバッグマッチングと、列構成が違うCSVを比べる列ユニオン

0
Last updated at Posted at 2026-08-13

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 にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?