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:
@@ -109,6 +109,7 @@ fn near_miss_matrix_simple_mode() {
|
||||
("delete", &["after `delete`, expected `from`", "delete from <Table>"]),
|
||||
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
|
||||
("delete from T", &["expected `where` or `--all-rows`", "delete from <Table>"]),
|
||||
("seed", &["after `seed`, expected table name", "seed <Table> [count]"]),
|
||||
("replay", &["after `replay`, expected string literal or path", "replay <path>"]),
|
||||
("explain", &["after `explain`, expected `show`, `update`, or `delete`", "explain show data"]),
|
||||
// advanced-only entry word typed in simple mode → "this is SQL" rail
|
||||
|
||||
@@ -78,6 +78,34 @@ fn seed_parses_with_and_without_count() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_parses_the_reproducibility_flag() {
|
||||
// `--seed <n>` after a count.
|
||||
match parse_command("seed People 5 --seed 42").expect("count + --seed parses") {
|
||||
Command::Seed {
|
||||
table,
|
||||
count,
|
||||
rng_seed,
|
||||
} => {
|
||||
assert_eq!(table, "People");
|
||||
assert_eq!(count, Some(5));
|
||||
assert_eq!(rng_seed, Some(42), "the value after --seed is the rng seed");
|
||||
}
|
||||
other => panic!("expected Command::Seed, got {other:?}"),
|
||||
}
|
||||
// `--seed <n>` with no count — the only number is the seed value,
|
||||
// not the count.
|
||||
match parse_command("seed People --seed 7").expect("--seed without count parses") {
|
||||
Command::Seed {
|
||||
count, rng_seed, ..
|
||||
} => {
|
||||
assert_eq!(count, None, "no positional count");
|
||||
assert_eq!(rng_seed, Some(7));
|
||||
}
|
||||
other => panic!("expected Command::Seed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_populates_a_table_and_persists_rows() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
@@ -534,3 +562,177 @@ fn seed_preview_is_capped_but_count_is_full() {
|
||||
assert_eq!(res.produced, 25, "the full count is produced");
|
||||
assert_eq!(res.data.rows.len(), 20, "the preview is capped at 20 rows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_is_available_in_advanced_mode() {
|
||||
use rdbms_playground::dsl::parser::parse_command_in_mode;
|
||||
use rdbms_playground::mode::Mode;
|
||||
// D5/A1: seed is a canonical command available in BOTH modes.
|
||||
let r = parse_command_in_mode("seed People 5", Mode::Advanced);
|
||||
assert!(
|
||||
matches!(r, Ok(Command::Seed { .. })),
|
||||
"seed must parse in advanced mode: {r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// — DA-pass coverage: undo (D15), replay (D16), atomicity, zero count,
|
||||
// complex-CHECK advisory (D17), FK reproducibility (D4) —
|
||||
|
||||
#[test]
|
||||
fn seed_is_one_undo_step() {
|
||||
// Undo must be explicitly enabled on the Database.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, true)
|
||||
.expect("open db with undo");
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
rt.block_on(db.seed("People".into(), Some(6), Some(1), Some("seed People 6".into())))
|
||||
.expect("seed");
|
||||
assert_eq!(data_row_count(&read_csv(&project, "People").unwrap()), 6);
|
||||
|
||||
// One undo removes the whole seed batch (ADR-0048 D15).
|
||||
rt.block_on(db.undo()).unwrap().expect("undo applied");
|
||||
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
|
||||
assert_eq!(rows, 0, "one undo must remove every seeded row in a single step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_reruns_a_seed_line_as_a_data_write() {
|
||||
use rdbms_playground::runtime::run_replay;
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
std::fs::write(project.path().join("seed.script"), "seed People 5\n").expect("write script");
|
||||
|
||||
// D16: seed is a data-write — replay re-runs it (it is NOT in the
|
||||
// app-lifecycle skip-list), so the rows appear.
|
||||
let _events = rt.block_on(run_replay(&db, project.path(), "seed.script"));
|
||||
assert_eq!(
|
||||
data_row_count(&read_csv(&project, "People").unwrap()),
|
||||
5,
|
||||
"replay must re-run the seed line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_rolls_back_atomically_on_a_constraint_failure() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
// A CHECK that generic text cannot satisfy → every generated row
|
||||
// violates it, so the whole batch must roll back (P1.3d atomicity).
|
||||
let mut code = ColumnSpec::new("note", Type::Text);
|
||||
code.check_sql = Some("length(note) > 100".to_string());
|
||||
rt.block_on(db.create_table(
|
||||
"Bad".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Serial), code],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
.expect("create Bad");
|
||||
|
||||
let res = rt.block_on(db.seed("Bad".into(), Some(5), Some(1), Some("seed Bad 5".into())));
|
||||
assert!(res.is_err(), "seed must fail when generated rows violate the CHECK");
|
||||
let rows = read_csv(&project, "Bad").map_or(0, |c| data_row_count(&c));
|
||||
assert_eq!(rows, 0, "a failed seed must leave the table unchanged (atomic)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_zero_is_a_no_op() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
let res = rt
|
||||
.block_on(db.seed("People".into(), Some(0), Some(1), Some("seed People 0".into())))
|
||||
.expect("seed 0 succeeds");
|
||||
assert_eq!(res.produced, 0);
|
||||
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
|
||||
assert_eq!(rows, 0, "seed 0 inserts nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_advises_on_a_complex_check_column() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
// A complex (non-IN) CHECK seed can't derive values from → the
|
||||
// column is filled generically AND flagged (D17/D13). `length` keeps
|
||||
// generic words valid so the seed still succeeds.
|
||||
let mut label = ColumnSpec::new("label", Type::Text);
|
||||
label.check_sql = Some("length(label) >= 1".to_string());
|
||||
rt.block_on(db.create_table(
|
||||
"Widgets".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Serial), label],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
.expect("create Widgets");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Widgets".into(), Some(3), Some(1), Some("seed Widgets 3".into())))
|
||||
.expect("seed");
|
||||
assert!(
|
||||
res.advisory_columns.contains(&"label".to_string()),
|
||||
"a column with an underivable CHECK should be flagged: {:?}",
|
||||
res.advisory_columns
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_foreign_keys_are_reproducible_with_a_fixed_seed() {
|
||||
let rt = rt();
|
||||
let seed_one = |db: &Database| {
|
||||
create_users_and_orders(db, &rt, true);
|
||||
rt.block_on(db.seed("Users".into(), Some(4), Some(1), Some("seed Users 4".into())))
|
||||
.expect("seed users");
|
||||
rt.block_on(db.seed("Orders".into(), Some(8), Some(99), Some("seed Orders 8".into())))
|
||||
.expect("seed orders");
|
||||
};
|
||||
let (p1, db1, _d1) = open_project_db();
|
||||
let (p2, db2, _d2) = open_project_db();
|
||||
seed_one(&db1);
|
||||
seed_one(&db2);
|
||||
// With ORDER BY on the FK sample, the same --seed reproduces the
|
||||
// sampled FK values (D4).
|
||||
assert_eq!(
|
||||
read_csv(&p1, "Orders").unwrap(),
|
||||
read_csv(&p2, "Orders").unwrap(),
|
||||
"FK sampling must be reproducible with a fixed --seed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
|
||||
let rt = rt();
|
||||
let make = |db: &Database| {
|
||||
rt.block_on(db.create_table(
|
||||
"Contacts".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("code", Type::ShortId),
|
||||
ColumnSpec::new("name", Type::Text),
|
||||
],
|
||||
vec!["code".to_string()],
|
||||
None,
|
||||
))
|
||||
.expect("create Contacts");
|
||||
rt.block_on(db.seed("Contacts".into(), Some(5), Some(42), Some("seed Contacts 5".into())))
|
||||
.expect("seed");
|
||||
};
|
||||
let (p1, db1, _d1) = open_project_db();
|
||||
let (p2, db2, _d2) = open_project_db();
|
||||
make(&db1);
|
||||
make(&db2);
|
||||
|
||||
let csv1 = read_csv(&p1, "Contacts").unwrap();
|
||||
let csv2 = read_csv(&p2, "Contacts").unwrap();
|
||||
assert_eq!(csv1, csv2, "shortid values must reproduce under a fixed --seed");
|
||||
|
||||
// The shortid PK is populated with distinct 10-char base58 ids.
|
||||
let codes = nth_column_values(&csv1, 0);
|
||||
assert_eq!(codes.len(), 5);
|
||||
let distinct: std::collections::HashSet<&String> = codes.iter().collect();
|
||||
assert_eq!(distinct.len(), 5, "shortid PK values must be distinct: {codes:?}");
|
||||
for code in &codes {
|
||||
assert_eq!(code.len(), 10, "shortid should be 10 chars: {code}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user