feat(seed): command plumbing + walking skeleton (ADR-0048 P1.2)

End-to-end `seed <table> [count]` path, both modes:
- Command::Seed AST + grammar node (show-data table slot + optional
  positional count) + REGISTRY registration + build_seed.
- Runtime dispatch -> Database::seed -> Request::Seed worker arm ->
  do_seed.
- do_seed (Phase-1 skeleton): generates whole rows for non-FK,
  non-autogen columns via the seed library and inserts them one at a
  time through do_insert (reusing validation / autogen autofill /
  FK-error / persistence). One undo step (snapshot_then wraps it) and
  one history.log line (only the first row carries the source);
  default count 20.
- help (`help seed`) + parse-usage catalog entries.
- Reuses CommandOutcome::Insert for the auto-show; a dedicated
  SeedResult (capped preview + advisory) replaces it in P1.3.

5 Tier-3 integration tests (parse, populate+persist, default-20,
reproducible --seed, one history line). 2327 pass / 0 fail / 0 skip,
clippy all-targets clean.

Deferred to P1.3: FK sampling, identifier/constraint uniqueness, CHECK
derivation, block guard, capped preview, advisory, multi-row path.
Deferred to P1.4: completion/highlight/hint/validity wiring + --seed flag.
This commit is contained in:
claude@clouddev1
2026-06-11 16:57:43 +00:00
parent 202e25a94f
commit f1e9484af3
11 changed files with 393 additions and 0 deletions
+149
View File
@@ -702,6 +702,15 @@ enum Request {
source: Option<String>,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
},
/// Populate a table with generated fake data (ADR-0048). One undo
/// snapshot wraps the whole seed via `snapshot_then`.
Seed {
table: String,
count: Option<u64>,
rng_seed: Option<u64>,
source: Option<String>,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
},
Update {
table: String,
assignments: Vec<(String, Value)>,
@@ -1491,6 +1500,26 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Populate a table with generated fake data (ADR-0048, SD1).
pub async fn seed(
&self,
table: String,
count: Option<u64>,
rng_seed: Option<u64>,
source: Option<String>,
) -> Result<InsertResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::Seed {
table,
count,
rng_seed,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn update(
&self,
table: String,
@@ -2646,6 +2675,24 @@ fn handle_request(
&values,
));
}
Request::Seed {
table,
count,
rng_seed,
source,
reply,
} => {
// One snapshot wraps the whole seed (ADR-0048 D15 — one undo
// step), exactly like a single insert.
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_seed(
conn,
persistence,
source.as_deref(),
&table,
count,
rng_seed,
));
}
Request::Update {
table,
assignments,
@@ -8636,6 +8683,108 @@ fn count_rows(conn: &Connection, table: &str) -> Result<i64, DbError> {
.map_err(DbError::from_rusqlite)
}
/// Default row count when `seed <T>` omits the count (ADR-0048 D6).
const DEFAULT_SEED_COUNT: u64 = 20;
/// Populate a table with generated fake data (ADR-0048, SD1).
///
/// **Phase 1 walking skeleton.** Generates whole rows for every user
/// column that is not an autogen `serial`/`shortid` and not a foreign
/// key, inserting them one at a time through [`do_insert`] — which
/// reuses all the existing per-value validation, autogen autofill,
/// FK-error enrichment and persistence machinery. The whole seed is a
/// single undo step (the worker wraps the call in one `snapshot_then`)
/// and writes exactly one `history.log` line (only the first row
/// carries the `source`).
///
/// Deferred to the next phase (ADR-0048): FK sampling from parent rows
/// (D14), the efficient single-transaction multi-row path, identifier
/// uniqueness (D10), the `IN`-CHECK value derivation (D17), the
/// required-column block guard (D1), the capped auto-show preview
/// (D18), and the enum/CHECK advisory (D12/D13).
fn do_seed(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
count: Option<u64>,
rng_seed: Option<u64>,
) -> Result<InsertResult, DbError> {
use crate::seed;
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let n = count.unwrap_or(DEFAULT_SEED_COUNT);
debug!(table = %table, count = n, "seed");
let schema = read_schema(conn, table)?;
// FK child columns are filled by the executor in a later phase; for
// now they are omitted (left to NULL / default).
let fk_children: std::collections::HashSet<&str> = schema
.foreign_keys
.iter()
.flat_map(|fk| fk.child_columns.iter().map(String::as_str))
.collect();
// Columns we generate values for: every user column that is not an
// autogen serial/shortid and not an FK child.
let gen_columns: Vec<&ReadColumn> = schema
.columns
.iter()
.filter(|c| {
!matches!(c.user_type, Some(Type::Serial) | Some(Type::ShortId))
&& !fk_children.contains(c.name.as_str())
})
.collect();
let col_names: Vec<String> = gen_columns.iter().map(|c| c.name.clone()).collect();
let mut rng = seed::make_rng(rng_seed);
let mut rows_affected = 0usize;
let mut last_data: Option<DataResult> = None;
for i in 0..n {
let values: Vec<Value> = gen_columns
.iter()
.map(|c| {
let ty = c.user_type.unwrap_or(Type::Text);
let spec = seed::ColumnSpec {
name: c.name.clone(),
ty,
not_null: c.notnull,
primary_key: c.primary_key,
unique: c.unique,
// FK children are already filtered out above.
is_foreign_key: false,
// `IN`-CHECK derivation is a later phase.
check_in_values: None,
};
let generator = seed::choose_generator(table, &spec);
seed::generate_value(&generator, ty, &mut rng)
})
.collect();
// Only the first row carries the `source`, so the whole seed
// writes exactly one `history.log` line.
let row_source = if i == 0 { source } else { None };
let result = do_insert(conn, persistence, row_source, table, Some(&col_names), &values)?;
rows_affected += result.rows_affected;
last_data = Some(result.data);
}
Ok(InsertResult {
rows_affected,
// `None` only when count was 0 — an empty result for the
// auto-show (the zero-no-op refinement lands in a later phase).
data: last_data.unwrap_or_else(|| DataResult {
table_name: table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
}),
})
}
fn do_insert(
conn: &Connection,
persistence: Option<&Persistence>,