feat(seed): --seed flag, ambient wiring, and /runda hardening (ADR-0048 P1.4 + DA)

P1.4 — user-visible surface:
- Grammar: `seed <table> [count] [--seed <n>]` (the first DSL flag with a
  value); build_seed disambiguates the seed value from the positional count.
- Verified the auto-wired surface: table-name completion, --seed offered as
  a candidate, validity consistent with `show data`, an ADR-0042 near-miss
  row for bare `seed`, and render tests for the seed outcome.

/runda hardening — eight DA findings, all resolved:
- FK sampling now uses ORDER BY so --seed reproducibility no longer relies
  on SQLite's unspecified DISTINCT order (D4).
- shortid columns now generate from seed's seeded RNG (new
  shortid::generate_with_rng) — D4 now holds with no exceptions.
- Added the missing coverage the DA flagged: undo-one-step (D15), replay
  re-runs a seed line (D16), advanced-mode (D5), atomic rollback on a
  constraint failure, seed 0 no-op, complex-CHECK advisory (D17), and
  FK + shortid reproducibility.

2358 pass / 0 fail / 0 skip, clippy all-targets clean.
This commit is contained in:
claude@clouddev1
2026-06-11 21:45:34 +00:00
parent e6ff63daa2
commit fbd219b631
8 changed files with 389 additions and 37 deletions
+70
View File
@@ -6258,6 +6258,76 @@ mod tests {
);
}
#[test]
fn seed_success_renders_count_preview_and_advisory() {
// ADR-0048: handle_dsl_seed_success renders the seeded-row count,
// the preview table, and the enum/CHECK advisory.
let mut app = App::new();
app.output
.push_back(OutputLine::echo("seed users 20", crate::mode::Mode::Simple));
app.update(AppEvent::DslSeedSucceeded {
command: Command::Seed {
table: "users".to_string(),
count: Some(20),
rng_seed: None,
},
result: crate::db::SeedResult {
table: "users".to_string(),
requested: 20,
produced: 20,
data: crate::db::DataResult {
table_name: "users".to_string(),
columns: vec!["name".to_string()],
column_types: vec![None],
rows: vec![vec![Some("Alice".to_string())]],
},
advisory_columns: vec!["status".to_string()],
},
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("20 row(s) seeded into users")),
"seeded-row count surfaced: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("status") && t.contains("generic text")),
"the advisory names the enum-ish column: {texts:?}",
);
}
#[test]
fn seed_success_reports_a_cap() {
// produced < requested → the cap note appears next to the count.
let mut app = App::new();
app.output
.push_back(OutputLine::echo("seed J 10", crate::mode::Mode::Simple));
app.update(AppEvent::DslSeedSucceeded {
command: Command::Seed {
table: "J".to_string(),
count: Some(10),
rng_seed: None,
},
result: crate::db::SeedResult {
table: "J".to_string(),
requested: 10,
produced: 4,
data: crate::db::DataResult {
table_name: "J".to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
advisory_columns: Vec::new(),
},
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("4 row(s) seeded into J")
&& t.contains("of 10 requested")),
"the cap note surfaces requested vs produced: {texts:?}",
);
}
#[test]
fn sql_delete_returning_renders_cascade_and_result_table() {
// ADR-0033 3g: a DELETE … RETURNING surfaces BOTH the cascade
+19 -3
View File
@@ -8724,6 +8724,10 @@ enum SeedColPlan {
/// column's slot within the parent key tuple (so a compound FK's
/// child columns all read from the *same* sampled parent row).
ForeignKey { fk_idx: usize, pos: usize },
/// A `shortid` column: a base58 id from seed's *seeded* RNG so it
/// reproduces under `--seed` (ADR-0048 D4). Always forced — a
/// `shortid` column needs an id, never a name-heuristic value.
ShortId,
}
/// Collision key for a positional list of seeded values, used to keep
@@ -8771,8 +8775,11 @@ fn sample_parent_key_tuples(
.map(|c| format!("\"{}\"", c.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(", ");
// `ORDER BY` the key columns so the sampled order is deterministic
// (ADR-0048 D4): `--seed` reproducibility must not depend on
// SQLite's unspecified `DISTINCT` row order.
let sql = format!(
"SELECT DISTINCT {cols} FROM \"{}\"",
"SELECT DISTINCT {cols} FROM \"{}\" ORDER BY {cols}",
parent_table.replace('"', "\"\"")
);
let n = parent_columns.len();
@@ -8877,8 +8884,9 @@ fn do_seed(
let mut advisory_columns: Vec<String> = Vec::new();
for c in &schema.columns {
let ty = c.user_type.unwrap_or(Type::Text);
// serial/shortid auto-fill in `do_insert`; omit them.
if matches!(ty, Type::Serial | Type::ShortId) {
// serial auto-fills deterministically in `do_insert` (rowid /
// MAX+1) — omit it. shortid is handled below from the seeded RNG.
if matches!(ty, Type::Serial) {
continue;
}
// blob has no DSL value path: refuse if required (D1), else omit.
@@ -8895,6 +8903,10 @@ fn do_seed(
col_names.push(c.name.clone());
if let Some(&(fk_idx, pos)) = fk_child_pos.get(c.name.as_str()) {
plans.push(SeedColPlan::ForeignKey { fk_idx, pos });
} else if matches!(ty, Type::ShortId) {
// Always the shortid generator (never a name heuristic — a
// shortid column needs a base58 id, not e.g. an email).
plans.push(SeedColPlan::ShortId);
} else {
// A simple `col IN ('a','b')` CHECK becomes the value source
// (D17) so the enum-as-CHECK pattern just works.
@@ -9028,6 +9040,10 @@ fn do_seed(
SeedColPlan::ForeignKey { fk_idx, pos } => {
fk_samples[*fk_idx][fk_choice[*fk_idx]][*pos].clone()
}
// Seeded base58 id → reproducible under `--seed` (D4).
SeedColPlan::ShortId => {
Value::Text(crate::dsl::shortid::generate_with_rng(&mut rng))
}
SeedColPlan::Generated { generator, ty }
if matches!(generator, crate::seed::Generator::IdentitySequential)
&& matches!(ty, Type::Int) =>
+49 -27
View File
@@ -430,16 +430,26 @@ const LIMIT_CLAUSE: Node = Node::Seq(LIMIT_CLAUSE_NODES);
// =================================================================
/// Optional positional row count. Reuses `LIMIT_VALIDATOR` (a
/// non-negative integer). Phase 1 has no `--seed` flag, `set` clause,
/// or `<table>.<column>` column-fill form yet.
/// non-negative integer).
const SEED_COUNT: Node = Node::NumberLit {
validator: Some(LIMIT_VALIDATOR),
};
/// `--seed <n>` — a reproducible-generation flag carrying a numeric
/// seed (ADR-0048 D4). The only flag in the DSL that takes a value;
/// `build_seed` reads the number immediately after the flag.
const SEED_FLAG_NODES: &[Node] = &[
Node::Flag("seed"),
Node::NumberLit {
validator: Some(LIMIT_VALIDATOR),
},
];
const SEED_FLAG: Node = Node::Seq(SEED_FLAG_NODES);
const SEED_NODES: &[Node] = &[
// `writes_table` so a future `set <col>=…` clause's column slots
// can resolve against this table.
TABLE_NAME_WRITES,
Node::Optional(&SEED_COUNT),
Node::Optional(&SEED_FLAG),
];
const SEED_SHAPE: Node = Node::Seq(SEED_NODES);
@@ -726,36 +736,48 @@ fn build_show_limit(path: &MatchedPath) -> Result<Option<u64>, ValidationError>
})
}
/// Build a `seed <T> [<count>]` command (ADR-0048). The only
/// `NumberLit` in a `seed` path is the optional count.
/// Build a `seed <T> [<count>] [--seed <n>]` command (ADR-0048). The
/// `--seed` flag's value is the `NumberLit` right after the flag; the
/// positional count is the `NumberLit` *before* the flag (or the only
/// one when no flag is present).
fn build_seed(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
let table = require_ident(path, "table_name")?;
let flag_idx = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Flag("seed")));
let rng_seed = flag_idx
.and_then(|fi| path.items.get(fi + 1))
.filter(|i| matches!(i.kind, MatchedKind::NumberLit))
.map(|i| parse_seed_u64(&i.text))
.transpose()?;
let count = path
.items
.iter()
.enumerate()
.find(|(idx, i)| {
matches!(i.kind, MatchedKind::NumberLit) && flag_idx.is_none_or(|fi| *idx < fi)
})
.map(|(_, i)| parse_seed_u64(&i.text))
.transpose()?;
Ok(Command::Seed {
table: require_ident(path, "table_name")?,
count: build_seed_count(path)?,
// `--seed <n>` is added in a later phase; reproducibility off
// for now.
rng_seed: None,
table,
count,
rng_seed,
})
}
fn build_seed_count(path: &MatchedPath) -> Result<Option<u64>, ValidationError> {
let Some(item) = path
.items
.iter()
.find(|i| matches!(i.kind, MatchedKind::NumberLit))
else {
return Ok(None);
};
item.text
.parse::<u64>()
.map(Some)
.map_err(|_| ValidationError {
message_key: "parse.custom.bind_type_mismatch",
args: vec![
("found", item.text.clone()),
("expected", "non-negative integer".to_string()),
],
})
fn parse_seed_u64(text: &str) -> Result<u64, ValidationError> {
text.parse::<u64>().map_err(|_| ValidationError {
message_key: "parse.custom.bind_type_mismatch",
args: vec![
("found", text.to_string()),
("expected", "non-negative integer".to_string()),
],
})
}
fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
+10 -6
View File
@@ -18,17 +18,21 @@ const DEFAULT_LEN: usize = 10;
pub const MIN_LEN: usize = 10;
pub const MAX_LEN: usize = 12;
/// Generate a fresh shortid using thread-local RNG.
/// Generate a fresh shortid using the thread-local RNG.
#[must_use]
pub fn generate() -> String {
generate_len(DEFAULT_LEN)
generate_with_rng(&mut rand::rng())
}
/// Generate a shortid from a caller-supplied RNG.
///
/// Lets `seed --seed <n>` produce **reproducible** shortid values
/// (ADR-0048 D4) by threading its seeded RNG through, while the default
/// [`generate`] keeps its thread-RNG behaviour for ordinary inserts.
#[must_use]
fn generate_len(len: usize) -> String {
let mut rng = rand::rng();
let mut out = String::with_capacity(len);
for _ in 0..len {
pub fn generate_with_rng<R: RngExt + ?Sized>(rng: &mut R) -> String {
let mut out = String::with_capacity(DEFAULT_LEN);
for _ in 0..DEFAULT_LEN {
let idx = rng.random_range(0..ALPHABET.len());
out.push(ALPHABET[idx] as char);
}
+1 -1
View File
@@ -100,7 +100,7 @@ fn generic_for_type(ty: Type, rng: &mut SeedRng) -> Value {
let words: Vec<String> = lorem::Words(2..4).fake_with_rng(rng);
Value::Text(words.join(" "))
}
Type::ShortId => Value::Text(crate::dsl::shortid::generate()),
Type::ShortId => Value::Text(crate::dsl::shortid::generate_with_rng(rng)),
Type::Int => Value::Number(rng.random_range(1..=10_000).to_string()),
Type::Serial => Value::Number(rng.random_range(1..=10_000).to_string()),
Type::Real => {