スキーマからリアルなテストデータを生成する zero-config な DB seeder を作った
はじめに
バックエンド開発をしていると、毎回同じ壁にぶつかります。本番には数百万行あるのに、開発用 DB には 2 行しか入っていない。ページネーションの 100 ページ目をテストできない。EXPLAIN ANALYZE を 50 行に対して打っても何も分からない。
この問題への定番の対処は、factory コードを書く・INSERT をスクリプトで流す・fixture ファイルを管理する、のいずれかです。しかしどれもスキーマが変わった瞬間に腐ります。カラムを 1 本足すたびに factory を直して回るのは、本質的な作業ではありません。
そこで seeder という CLI ツールを作りました。DSN を渡すだけで、スキーマから推論してリアルなダミーデータを流し込みます。
seeder とは
ひとことで言うと「zero-config な DB seeder」です。設定ファイルも factory コードも要りません。MySQL / PostgreSQL の DSN を渡すと、
- テーブル・カラム・主キー・外部キー・enum をイントロスペクトする
- 各カラムについて、名前と SQL 型から「それっぽいジェネレータ」を推論する
- 外部キー依存をトポロジカルソートして、親 → 子の順に
- バルクインサート (MySQL は multi-row
INSERT、Postgres はCOPY) で流し込む
という流れで動きます。
$ seeder postgres://user:pass@localhost:5432/mydb --rows 1000 --truncate --seed 42
seeder: 3 table(s), 3 FK(s)
order: users -> orders -> comments
mode: truncate + insert
users 1000 rows (6.7ms)
orders 1000 rows (7.0ms)
comments 1000 rows (9.9ms)
done: 3000 row(s) in 53ms
インストール
Homebrew が一番手軽です。
brew install mickamy/tap/seeder
Go が入っていれば go install でも入ります (ビルドには Go 1.26+ が必要)。
go install github.com/mickamy/seeder@latest
Windows 向けには Releases に zip があります。macOS / Linux / Windows × amd64 / arm64 のビルド済みバイナリがタグごとに公開されます。
使い方
基本は DSN を渡すだけです。
seeder postgres://user:pass@localhost:5432/mydb
seeder mysql://root:pass@localhost:3306/mydb
よく使うフラグ:
| フラグ | 説明 |
|---|---|
--rows N |
テーブルあたりの行数 (default 1000) |
--truncate |
INSERT 前に TRUNCATE する (default は append) |
--seed N |
乱数シードを固定して再現可能にする |
--locale ja |
名前・住所などを日本語ロケールにする |
--dry-run |
プランだけ表示して INSERT しない |
--verbose |
カラムごとの推論結果を表示する |
--exclude t1,t2 |
指定テーブルをスキップ |
--tables t1,t2 |
指定テーブルだけ対象にする |
--verbose を付けると、どのカラムがどのルールにマッチしたかが分かります。
$ seeder $DATABASE_URL --rows 5 --verbose
seeder: 3 table(s), 3 FK(s)
order: users -> posts -> comments
mode: dry-run (no INSERT)
users
id skip: serial default
email unique-aware name match: Email
name name match: Name
role kind: string
active name match: Bool
created_at name match: PastDate
posts
id skip: serial default
user_id fk: users.id
title name match: LoremIpsumSentence
body name match: LoremIpsumParagraph
tags kind: json
view_count kind: int
published kind: bool
created_at name match: PastDate
仕組み
スキーマのイントロスペクト
information_schema (Postgres では enum 型や複合 FK の列順のために pg_catalog / pg_constraint も) を読んで、テーブル・カラム・主キー・外部キー・enum ラベルを取得します。読み取りは標準の SELECT のみ。スキーマ変更も特権アクセスも要りません。
カラムの推論
各カラムはまず名前パターンにマッチさせ、外れたら SQL 型でフォールバックします。
| パターン | ジェネレータ |
|---|---|
email, *_email
|
メールアドレス |
name, first_name, ... |
人名 |
phone, tel, mobile
|
電話番号 |
*_url, link, homepage
|
URL |
created_at, updated_at, *_at
|
過去 1 年のタイムスタンプ |
price, amount, *_yen
|
金額レンジの int |
is_*, has_*, *_flag
|
boolean |
| Postgres enum | ラベルからランダム |
| それ以外 | 推論した Kind でフォールバック |
--locale ja を付けると、人名・住所・都道府県・電話番号・郵便番号などが日本語の辞書に切り替わります。email や *_url のようなロケール非依存のものは英語のままです。
DB に任せるカラム
次の 2 つだけは seeder が値を生成せず、DB に任せます。
-
IDENTITYカラム (Postgres のGENERATED ALWAYS AS IDENTITY/ MySQL のAUTO_INCREMENT) - デフォルトが Postgres のシーケンス呼び出し (
DEFAULT nextval(...)) のもの。これはserial/bigserialが展開された形です
逆に、それ以外は DEFAULT が付いていても seeder が値を生成して上書きします。status text DEFAULT 'active' のような列を全行同じ値にしてしまうとテストデータとして役に立たないからです。
UNIQUE 制約への配慮
単一カラムの UNIQUE 制約が付いた列は、衝突を避けるジェネレータにルーティングされます。email なら <uuid>@example.com、その他の文字列は名前ルールに UUID サフィックスを足し、整数列はずっと広いレンジに広げます。
外部キーの解決
テーブルは依存順 (親 → 子) に挿入され、子は各 FK 列について実在する親の PK をランダムに選びます。
- 複合 FK: 親行を一度選んでから参照列を割り当てるので、別々の親の列を混ぜた不整合な行になりません
-
ポリモーフィック関連 (Rails 風の
*_type+*_id):information_schemaからは検出できないのでseeder.yamlで宣言します -
自己参照 FK (
employees.manager_id → employees.id): バッチ内の前方参照を解決します -
本物の循環 (
A → B → A): 満たせる順序が無いのでエラーで報告します
seeder.yaml で細かく制御する
スキーマだけでは決められないものは、seeder.yaml を置くと自動検出されます。CLI フラグ > seeder.yaml > ビルトインのデフォルト、の優先順です。
rows: 1000
seed: 42
locale: en
truncate: false
tables:
users:
rows: 5000
columns:
email:
generator: Email
bio:
value: dogfood seed row
orders:
rows: 10000
comments:
exclude: true
カラム単位の上書きは generator (ビルトインを強制) か value (リテラルを固定) のどちらか一方を指定します。ポリモーフィック関連はこう書きます。
tables:
comments:
polymorphic:
- type_col: commentable_type
id_col: commentable_id
targets:
- { table: posts, type: Post }
- { table: articles, type: Article }
seeder.yaml はロード時に検証され、タイプミス (例: truncates:) は行番号付きで弾かれます。
その他の出力モード
DB に挿入する以外の使い道もあります。
-
--output sql:INSERT文を吐き出す。migration リポジトリにseed.sqlをコミットしたいとき向け -
--output ndjson: 1 行 1 JSON で_table付き。ETL / streaming パイプラインに流すとき向け -
--stream --rate N: 初回シード後、毎秒およそ N 行を追記し続ける。CDC やレプリケーション遅延のテスト向け -
--cache <file>: イントロスペクト結果をキャッシュして再実行を高速化
技術的な話
- 言語: Go 1.26、単一の静的バイナリ
- DB ドライバ:
jackc/pgx/v5(Postgres はCopyFromで高速パス) とgo-sql-driver/mysql - 値生成:
brianvoe/gofakeit/v7 - FK 順序づけ: トポロジカルソートを自前実装
- リリース: goreleaser で各 OS / arch のバイナリと Homebrew formula を自動公開
大量行でもメモリが膨らまないよう、--batch-size (default 1000) ごとに生成 → フラッシュを繰り返します。FK 解決用の親 PK プールも (テーブル, 列) あたり 10 万件で上限を設けています。
ちなみに json / jsonb 列は、デフォルトで {"id": ..., "label": ..., "count": ..., "active": ...} という ~85 バイトの固定形オブジェクトを生成します。以前は完全ランダムな JSON を吐いていて 1 行が数 KB になることがあり、大量行で重かったため、最近この形に変えました。カラム固有の形が欲しければ value で上書きできます。
おわりに
seeder はまだ pre-1.0 で、現状は en / ja の 2 ロケール、JSON はプレースホルダ、という段階です。今後の方向性としては、
- ロケールの追加
- LLM 支援によるリアルなテキスト生成
- 既存 DB の統計からのサンプリング (合成生成より実データ分布に近づける)
あたりを検討しています。「合成生成よりサンプリングのほうが嬉しい」みたいな意見があればぜひ聞きたいです。
フィードバック・Issue・PR お待ちしています。
Discussion