Seeding
besu::seed generates rows from a table's declaration. It is for the data a test needs but
does not care about: names, emails, timestamps and foreign keys that only have to exist and
be consistent. The rows go through the ordinary typed insert builder, in batches, inside one
transaction, so a seed is checked by the same compiler and rendered by the same statement
builder as a hand-written insert.
seed borrows the database and returns a builder. Each table names a table and a row count,
and awaiting the builder runs the whole plan in one transaction: either every table is
seeded, or none is. SeedReport records how many rows went where.
Determinism
Every value is generated from SplitMix64, seeded by the master seed, the table's name and the row's index. The same seed and the schema produce the same rows on every run:
seed(0) is the default. The number is part of the data, so changing it changes every row;
keeping it fixed is what lets a failing test be replayed by number instead of by fixture.
What each column gets
The derive implements Seedable and fills each column from its SqlType:
The window is fixed rather than relative to the clock, so a seeded timestamp does not change
between runs. A #[besu(generated = "..")] column is never written; the database computes it
from the columns the seed does fill.
Unique columns
A column marked #[besu(unique)], and any primary key that is not auto-incrementing, is
generated uniquely. Text gets a row counter appended (an email keeps its shape:
ada.lovelace17@example.com), integers and decimals include the row number, dates and
timestamps step through their window by row, and a UUID is unique by construction.
A unique BOOLEAN, or a unique integer narrow enough that the row count does not fit, cannot
be made unique by any generator; the insert reports the constraint violation.
Foreign keys
Tables are seeded in the order they appear in the chain. A table records the values it wrote
for its primary-key and unique columns, and a references column picks from the parent's
pool:
Listing the child first is an error, not a dangling key:
The same applies to a parent seeded with zero rows. A self-referencing table is not
supported: the key pool for a table is filled from the rows it has already inserted, and the
first row is generated before any key exists, so seeding one fails with
cannot seed a foreign key referencing .... set pins one value for every row and cannot
carve out a single root. Seed the roots with a plain insert first, then the rest — or leave
the column nullable and write the parent references yourself.
The referenced column must be a primary key or a unique column, because that is what SQL
requires of a foreign key. A reference written with a schema prefix
(references = "audit.users.id") is looked up by its last table segment.
Overrides
set pins a column to a value for every row of the table:
The value is checked against the column's SQL type at compile time. None::<String> writes an
explicit NULL, while a value replaces the generated one rather than being appended to it.
Overriding a unique column to one value only makes sense when the table has one row.
Custom column types
A custom sql_type has no honest default, so the derive leaves that column unset and lists it
as required when SQL would also refuse a missing value. The seed fails before inserting
anything, naming the column:
A nullable custom column, or one with a database default, is simply omitted and the database fills it in.
Batching
values_chunked writes several rows per statement. The default is 500 rows per statement,
which keeps a wide table under every supported database's parameter limit:
The whole seed is one transaction regardless of the batch size. That is deliberate: a partially seeded parent table is worse than an empty one.
What it does not do
- No fixtures by example. There is no
factoryfile, no named dataset and no way to say "one admin and three editors". Those rows matter, so write them with the insert builder. - No inference from a live database. A table's columns come from
#[derive(Table)], not from introspection.migratecan compare the two, but seeding does not. - No cycle resolution. Parent-first ordering is the caller's job. A schema with a cycle needs an override at the point where it closes.
- No parallel generation. The rows are generated and inserted table by table.
- No
RETURNINGround trip. Auto-incrementing keys are written explicitly so a child can refer to them on MySQL as well as PostgreSQL and SQLite. That means a seed expects an empty table; seeding a table that already has rows collides on the key, so use.setthere.
Without the generator
A seed is still allowed to be ordinary code. When the rows are part of the deployed state — a lookup table, a default role — a migration is better: it runs once, in order, inside the migration runner's transaction, and the journal records that it happened.
The file is applied by besu migrate, or by Migrator::from_dir plus db.migrate(..) at
start-up. ON CONFLICT is PostgreSQL and SQLite spelling; MySQL needs another form of
idempotence. Writing the .down.sql is optional but expected: without it the migration
cannot be reverted.
Gotchas
- There is no environment guard. A seeding binary pointed at production will seed production. Check the connection string yourself before it runs.
- A seed migration is forever. It runs once per database, and editing an applied file is refused because the journal checksums it. Fix data with a new migration.
- The seed owns the table's columns. A column added to the struct is generated on the next run, which is usually what a test wants but is a change to the data as well as the code.
