技術ガイド
PostgreSQLのCSVインポートを検証する方法
顧客データのインポート後に、行数以外も確認します。PostgreSQLのSQLで、受け入れたキー、変換後の値、テナントの範囲、未解決の結果を比較します。
安定したキーを使って、受け入れた入力レコードと、コミット済みの取り込み先レコードを比較し、その後、重要な値と業務上の不変条件を確認します。除外、書き込みの失敗、未解決の結果は、それぞれ区別してください。件数の一致は最初の確認として有用ですが、正しいレコードが取り込まれたかどうかは分かりません。
端的に言えば、挿入は挿入を証明し、照合は正しさを証明するということです。ただし後半には条件があり、照合で正しさを確認できるのは、実際に選んだ比較と不変条件に対してのみです。明示していない業務ルールは証明できません。
以下の例では、行数の確認を通過してしまう誤ったインポートを示し、その検出方法を解説します。
対象範囲と前提条件. PostgreSQL 17を対象とします。架空のデータを使った一時テーブルで、受け入れた1つのテナント内の集合を照合する例を示します。顧客データには接続しません。
検証・挿入・照合はそれぞれ異なる問いに答える
| 段階 | 問い | 限界 |
|---|---|---|
| 事前検証 | この予定データは、確認した規則を満たしているか? | 実際の状態、並行した変更、トリガーの実際の動作を確認できない場合がある |
| 挿入/コミットの証拠 | これらのデータベース書き込みはコミットされたか? | 元データの識別情報や業務上の意味は確認できない |
| 照合 | 確認した結果は、受け入れた入力データと、選んだ不変条件に一致するか? | 実行した範囲、スナップショット、比較だけが対象となる |
UIと引き継ぎでも、この区別を維持します。「CSVを解析できた」は「インポートが検証に合格した」ではありません。「サーバーが受信した」は「データベースがコミットした」ではありません。「200件挿入した」は「顧客データがすべて正しい」ではありません。
DDLチェッカーは、こうした実際の結果の確認より前に使うものです。PG Import Checkは、指定したSQLに宣言された要件や、解析でカバーできない点を明らかにできます。CSVレコードの検証、取り込み先への照会、照合は行いません。
期待する結果を確定して保存する
実行前に、レビュー済みの受け入れ対象の集合を保存します。書き込む入力レコード、承認済みの変換後の値、テナント、関係する入力元システムの名前空間を特定できるようにしてください。マッピングと規則のバージョンも併せて保持します。
除外は明示的に記録します。205レコードのファイルで連絡先5件を意図的に除外したなら、挿入予定の集合は200件です。一方、結合で親が見つからずに5件が落ちた場合は、その扱いが承認されるまで、説明のつかない欠落です。
安定したキーを選びます。テナント内の外部連絡先参照、取り込み先で生成したIDの対応表、入力レコードと取り込み先の識別情報を結び付けるインポート台帳などが候補になります。メールアドレスは、常に適切なキーとは限りません。変更、共有、欠落の可能性があるためです。
取り込み先に、照合に使える安定した識別情報や、信頼できるインポートの帰属情報がない場合は、書き込み前にその限界を認識してください。タイムスタンプによる時間範囲や件数の差には、並行するアプリケーションの処理も含まれる可能性があります。今回のインポートの行を特定できるレビュー済みの方法を追加するか、検証で保証する範囲を狭めます。
実例:件数は同じでもレコードが異なる
使い捨てのPostgreSQL 17セッションで、このセットアップを実行します。両テーブルは、レビュー対象のテナントだけを表します。この例では、主キーが一意性を保証しています。
CREATE TEMP TABLE import_expected_demo (
tenant_id integer NOT NULL,
external_ref text NOT NULL,
display_name text,
PRIMARY KEY (tenant_id, external_ref)
);
CREATE TEMP TABLE import_actual_demo (
tenant_id integer NOT NULL,
external_ref text NOT NULL,
display_name text,
PRIMARY KEY (tenant_id, external_ref)
);
INSERT INTO import_expected_demo VALUES
(10, 'contact-a', 'Ada'),
(10, 'contact-b', 'Bela'),
(10, 'contact-c', NULL);
INSERT INTO import_actual_demo VALUES
(10, 'contact-a', 'Ada'),
(10, 'contact-c', ''),
(10, 'contact-d', 'Dara');
SELECT
(SELECT count(*) FROM import_expected_demo WHERE tenant_id = 10)
AS expected_count,
(SELECT count(*) FROM import_actual_demo WHERE tenant_id = 10)
AS actual_count;どちらの件数も3件です。しかし、連絡先Bが欠落し、予定になかった連絡先Dが存在し、連絡先Cの名前がNULLではなく空文字列になっています。この1対1の期待値では件数の一致は必要ですが、それだけでは正しさを確認できない理由が、ここにあります。
集合の要素を双方向に比較する
受け入れた業務キーを使って、欠落したレコードを探します。
SELECT external_ref
FROM import_expected_demo WHERE tenant_id = 10
EXCEPT
SELECT external_ref
FROM import_actual_demo WHERE tenant_id = 10;これはcontact-bを返します。比較の向きを逆にして、予定外のレコードを探します。
SELECT external_ref
FROM import_actual_demo WHERE tenant_id = 10
EXCEPT
SELECT external_ref
FROM import_expected_demo WHERE tenant_id = 10;これはcontact-dを返します。既存の連絡先を含む本番テーブルでは、テナント全体を新規インポートの受け入れ対象と比較し、古い連絡先をすべて予定外と判断してはいけません。信頼できるインポートの帰属情報を使って実際の行の範囲を限定するか、明示的に構築したインポート後の全体の期待値と比較してください。
EXCEPTは出力行の重複を除去します。ここでは、主キーが一意性を保証しています。その保証がないと、集合の一致によって重複件数の誤りが隠れることがあります。キーごとの件数を確認するか、NULLと正規化の意味を理解したうえで、EXCEPT ALLなど適切な多重集合の比較を使ってください。
NULLを考慮して値を比較する
集合に含まれるレコードを確認したら、重要なフィールドを比較します。意図的に正規化する前の生の文字列ではなく、期待する変換後の表現を使ってください。
SELECT expected.external_ref,
expected.display_name AS expected_name,
actual.display_name AS actual_name
FROM import_expected_demo AS expected
JOIN import_actual_demo AS actual
ON actual.tenant_id = expected.tenant_id
AND actual.external_ref = expected.external_ref
WHERE expected.tenant_id = 10
AND expected.display_name IS DISTINCT FROM actual.display_name;これは連絡先Cを返します。通常の<>比較では、NULLが関わると結果が不明となるため、WHERE句で不一致を見落とすことがあります。IS DISTINCT FROMを使うと、この例のようにNULLを考慮した比較ができます。この結合は、意図的に一致したキーを対象としています。キーの欠落の確認を置き換えるものではありません。
データの管理責任者とともに、業務上の不変条件を選びます。連絡先のインポートなら、承認済みのテナントと会社の関係、許可されたライフサイクルのステータス、外部参照の保持などが考えられます。請求書のインポートなら、通貨別・文書状態別の合計などです。全体の合計1つだけでは、互いに打ち消し合う誤りが隠れることがあります。
データベースで生成した値は、意図した性質に従って比較します。新しく生成されたIDが旧システムのIDと一致することを期待してはいけません。計算による合計では、合意済みの計算方法と丸めをテストします。監査用タイムスタンプでは、文書化した時刻の規則をテストしてください。生成値とデフォルト値を参照してください。
すべての入力レコードを一度だけ計上する
ワークフローの状態を、互いに重複しないよう定義します。挿入専用のインポートでは、承認済みの除外、コミット済みと確認できたレコード、挿入されていないと確認できたレコード、未解決のレコードを分けると、最終的な集計に役立ちます。未実行の処理は、別の理由を付けて未挿入の区分に含めます。データベースの検査に失敗したかのように示してはいけません。
205 parsed source records
5 approved exclusions
196 confirmed committed
2 confirmed non-inserted (rejected batch)
2 unresolved (commit evidence unavailable)
---
205 accounted for; import is not yet completeこれは集計規約の一例であり、汎用的なスキーマではありません。upsertのワークフローでは、挿入、更新、意図的な変更なしも区別する必要があります。入力の重複を解消すると、複数の入力レコードが1つの受け入れ対象エンティティを表す場合があります。不適切な1行対1行の等式に無理に当てはめず、その関係を記録してください。
ネットワークのタイムアウトは、書き込みが0件だった証拠にはなりません。その試行を特定できる永続的な処理記録や、データベースの証拠を確認します。結果が分からないままなら、不明として扱い、無条件の全件再試行を止めます。インポーター設計ガイドでは、書き込みと処理記録をアトミックに保存するパターンを説明しています。
一貫したスナップショットを使い、アプリも確認する
異なる時点で複数のクエリを実行すると、それぞれが異なるデータベース状態を示す可能性があります。稼働中のアプリケーションでは、書き込み時間帯を管理するのか、一貫したデータベーススナップショットを使うのか、インポート固有の不変なビューを使うのかを決めてください。その選択に伴うコストも事前検証します。長いトランザクションは、運用に影響します。
PostgreSQLのデフォルトの分離レベルであるRead Committedでは、別々の文が異なるコミット済みスナップショットを見る場合があります。Repeatable Readは、一貫したトランザクションスナップショットを提供しますが、実際の業務を停止させるわけでも、そのスナップショットより後にコミットされたトランザクションを含めるわけでもありません。分離レベルのドキュメントを参照してください。
データベースで比較した後は、想定するユーザーでアプリケーションの動作を確認します。行が存在していても、RLSで非表示になっていたり、別のテナントに関連付けられていたり、ステータスのフィルターで除外されていたり、必要な関連が欠けていたりする場合があります。後続の影響も確認してください。メールや外部APIの呼び出しには、挿入行とは別の証拠が必要なことがあります。
サンプリングは、形式やUXの問題を見つける助けになりますが、全行の一致を証明するものではありません。どの検査が受け入れ対象全体をカバーし、どの検査がサンプルだったかを明示してください。
受け入れの記録を後から役立つものにする
受け入れた入力データの識別情報、マッピングと規則のバージョン、取り込み先とテナント、実行の証拠、除外、比較結果、未解決の確認事項を、簡潔な引き継ぎ資料にまとめます。業務上の意味を誰がレビューしたか、いつ検証したかも含めてください。
通常のログや公開チケットに、顧客の生データを含めないでください。診断データは認可された場所にだけ保持し、アクセスを制限し、削除について合意します。外部参照は、名前やメールアドレスでなくても、それ自体が機密情報になり得ます。
検査に失敗したら、本番環境を変更する前に調査します。入力値の修正、マッピングの修復、コミット済みの書き込みを補償する変更は、それぞれ異なる操作です。修復によって説明すべき事実が消えないよう、元の結果の証拠を保持してください。