feat(seed): set override clause + column-fill (ADR-0048 Phase 2)
Build the two SD2 surfaces Phase 1 deferred:
- `set` override clause (D2): comma-separated per-column pins —
`= 'v'` (fixed), `in ('a','b')` (pick-list), `as <generator>`
(named), `between x and y` (range; numeric and quoted dates).
Type-aware via the typed `current_column_value` slot; an override
drops its column from the generic-fill advisory (D13). Folded from
the flat matched path (build_seed_overrides) and applied to the
per-column plan (apply_seed_overrides).
- `<table>.<column>` column-fill (D1 form 2): an UPDATE over existing
rows. Refuses PK/autogen targets, empty-table no-op, FK-samples the
parent, collision-free for UNIQUE/identifier targets, one undo step;
`set` may only adjust the filled column.
Supporting work: KNOWN_GENERATORS vocabulary + generator_for_name
(src/seed/vocabulary.rs, D9); a range Generator + range_bounds_reason;
IdentSource::Generators and HighlightClass::Function; completion of the
generator vocabulary after `as` and the set/.col column slots; the
typing-time validity indicator for an unknown generator; help,
parse-error pedagogy rows, and the D13 advisory's Phase-2/3 wording.
A bounded override (fixed value / too-short pick-list) on a
single-column-UNIQUE target is a friendly error rather than a silent
uniqueness cap (post-implementation /runda finding, user-chosen).
Dates in the range form are quoted (no date-literal token exists);
ADR-0048 D2 amended accordingly. Both modes (D5); reproducible (D4).
This commit is contained in:
@@ -110,6 +110,13 @@ fn near_miss_matrix_simple_mode() {
|
||||
("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]"]),
|
||||
// Phase 2 (ADR-0048 D2/D1): malformed `set` clause + column-fill.
|
||||
("seed T set", &["after `seed T set`, expected column name", "seed <Table>.<col>"]),
|
||||
(
|
||||
"seed T set role",
|
||||
&["after `seed T set role`, expected `=`, `in`, `between`, or `as`", "seed <Table>.<col>"],
|
||||
),
|
||||
("seed T.", &["after `seed T.`, expected column name", "seed <Table>.<col>"]),
|
||||
("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
|
||||
@@ -540,3 +547,4 @@ fn caret_aligns_under_offending_token() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+564
-25
@@ -60,11 +60,15 @@ fn seed_parses_with_and_without_count() {
|
||||
match parse_command("seed People 5").expect("`seed People 5` parses") {
|
||||
Command::Seed {
|
||||
table,
|
||||
target_column,
|
||||
count,
|
||||
overrides,
|
||||
rng_seed,
|
||||
} => {
|
||||
assert_eq!(table, "People");
|
||||
assert_eq!(target_column, None);
|
||||
assert_eq!(count, Some(5));
|
||||
assert!(overrides.is_empty());
|
||||
assert_eq!(rng_seed, None);
|
||||
}
|
||||
other => panic!("expected Command::Seed, got {other:?}"),
|
||||
@@ -86,6 +90,7 @@ fn seed_parses_the_reproducibility_flag() {
|
||||
table,
|
||||
count,
|
||||
rng_seed,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(table, "People");
|
||||
assert_eq!(count, Some(5));
|
||||
@@ -106,6 +111,155 @@ fn seed_parses_the_reproducibility_flag() {
|
||||
}
|
||||
}
|
||||
|
||||
// — Phase 2 (SD2): set-clause + column-fill parse path (ADR-0048 D2/D1) —
|
||||
|
||||
use rdbms_playground::dsl::command::{SeedOverride, SeedOverrideKind};
|
||||
use rdbms_playground::dsl::value::Value;
|
||||
|
||||
/// Pull the `overrides` out of a parsed `seed` command (panics on a
|
||||
/// non-seed command), for the builder-fold assertions below.
|
||||
fn seed_overrides(input: &str) -> (Option<String>, Vec<SeedOverride>) {
|
||||
match parse_command(input).unwrap_or_else(|e| panic!("`{input}` should parse: {e:?}")) {
|
||||
Command::Seed {
|
||||
target_column,
|
||||
overrides,
|
||||
..
|
||||
} => (target_column, overrides),
|
||||
other => panic!("expected Command::Seed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_fixed_value_override_parses() {
|
||||
let (_t, ov) = seed_overrides("seed users 5 set status = 'active'");
|
||||
assert_eq!(ov.len(), 1);
|
||||
assert_eq!(ov[0].column, "status");
|
||||
assert_eq!(ov[0].kind, SeedOverrideKind::Fixed(Value::Text("active".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_pick_list_override_parses() {
|
||||
let (_t, ov) = seed_overrides("seed users set role in ('admin', 'editor', 'viewer')");
|
||||
assert_eq!(ov.len(), 1);
|
||||
assert_eq!(ov[0].column, "role");
|
||||
assert_eq!(
|
||||
ov[0].kind,
|
||||
SeedOverrideKind::PickList(vec![
|
||||
Value::Text("admin".into()),
|
||||
Value::Text("editor".into()),
|
||||
Value::Text("viewer".into()),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_generator_override_parses() {
|
||||
let (_t, ov) = seed_overrides("seed users set work_addr as email");
|
||||
assert_eq!(ov.len(), 1);
|
||||
assert_eq!(ov[0].column, "work_addr");
|
||||
assert_eq!(ov[0].kind, SeedOverrideKind::Generator("email".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_numeric_range_override_parses() {
|
||||
let (_t, ov) = seed_overrides("seed products set price between 10 and 100");
|
||||
assert_eq!(ov.len(), 1);
|
||||
assert_eq!(ov[0].column, "price");
|
||||
assert_eq!(
|
||||
ov[0].kind,
|
||||
SeedOverrideKind::Range {
|
||||
low: Value::Number("10".into()),
|
||||
high: Value::Number("100".into()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_date_range_override_parses_with_quoted_dates() {
|
||||
// ADR-0048 D2 amendment: dates in the range form are quoted strings.
|
||||
let (_t, ov) =
|
||||
seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
|
||||
assert_eq!(
|
||||
ov[0].kind,
|
||||
SeedOverrideKind::Range {
|
||||
low: Value::Text("2023-01-01".into()),
|
||||
high: Value::Text("2024-12-31".into()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_multiple_overrides_combine() {
|
||||
let (_t, ov) = seed_overrides(
|
||||
"seed users 20 set role in ('admin', 'user'), status = 'active', signup between '2023-01-01' and '2024-12-31'",
|
||||
);
|
||||
assert_eq!(ov.len(), 3, "three comma-separated overrides: {ov:?}");
|
||||
assert_eq!(ov[0].column, "role");
|
||||
assert!(matches!(ov[0].kind, SeedOverrideKind::PickList(_)));
|
||||
assert_eq!(ov[1].column, "status");
|
||||
assert!(matches!(ov[1].kind, SeedOverrideKind::Fixed(_)));
|
||||
assert_eq!(ov[2].column, "signup");
|
||||
assert!(matches!(ov[2].kind, SeedOverrideKind::Range { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_count_is_not_confused_by_a_range_value() {
|
||||
// No positional count, but `between 18 and 80` carries NumberLits —
|
||||
// they must not be read as the count (bounded to before `set`).
|
||||
match parse_command("seed users set age between 18 and 80").expect("parses") {
|
||||
Command::Seed { count, overrides, .. } => {
|
||||
assert_eq!(count, None, "the count is None, not 18");
|
||||
assert_eq!(overrides.len(), 1);
|
||||
}
|
||||
other => panic!("expected seed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_combines_with_count_and_flag() {
|
||||
match parse_command("seed users 30 set status = 'x' --seed 42").expect("parses") {
|
||||
Command::Seed {
|
||||
count,
|
||||
overrides,
|
||||
rng_seed,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(count, Some(30));
|
||||
assert_eq!(rng_seed, Some(42));
|
||||
assert_eq!(overrides.len(), 1);
|
||||
}
|
||||
other => panic!("expected seed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_target_parses() {
|
||||
let (target, ov) = seed_overrides("seed users.work_addr");
|
||||
assert_eq!(target.as_deref(), Some("work_addr"));
|
||||
assert!(ov.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_with_set_parses() {
|
||||
let (target, ov) = seed_overrides("seed users.work_addr set work_addr as email");
|
||||
assert_eq!(target.as_deref(), Some("work_addr"));
|
||||
assert_eq!(ov.len(), 1);
|
||||
assert_eq!(ov[0].kind, SeedOverrideKind::Generator("email".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_bare_word_set_value_is_rejected() {
|
||||
// A bare (unquoted) word is not a value — D2 requires quoting. The
|
||||
// typed value slot rejects `active` at the grammar level (it is not a
|
||||
// quoted string / number), so the command does not parse.
|
||||
assert!(
|
||||
parse_command("seed users set status = active").is_err(),
|
||||
"a bare-word `set` value must be rejected (quoting required, D2)"
|
||||
);
|
||||
// The quoted form parses.
|
||||
assert!(parse_command("seed users set status = 'active'").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_populates_a_table_and_persists_rows() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
@@ -113,7 +267,7 @@ fn seed_populates_a_table_and_persists_rows() {
|
||||
create_people(&db, &rt);
|
||||
|
||||
let result = rt
|
||||
.block_on(db.seed("People".into(), Some(7), Some(42), Some("seed People 7".into())))
|
||||
.block_on(db.seed("People".into(), None, Some(7), Vec::new(), Some(42), Some("seed People 7".into())))
|
||||
.expect("seed succeeds");
|
||||
assert_eq!(result.produced, 7);
|
||||
|
||||
@@ -134,7 +288,7 @@ fn seed_count_defaults_to_twenty() {
|
||||
create_people(&db, &rt);
|
||||
|
||||
let result = rt
|
||||
.block_on(db.seed("People".into(), None, Some(1), Some("seed People".into())))
|
||||
.block_on(db.seed("People".into(), None, None, Vec::new(), Some(1), Some("seed People".into())))
|
||||
.expect("seed succeeds");
|
||||
assert_eq!(result.produced, 20, "omitted count defaults to 20");
|
||||
let csv = read_csv(&project, "People").expect("People CSV exists");
|
||||
@@ -149,9 +303,9 @@ fn seed_is_reproducible_with_a_fixed_seed() {
|
||||
create_people(&db1, &rt);
|
||||
create_people(&db2, &rt);
|
||||
|
||||
rt.block_on(db1.seed("People".into(), Some(4), Some(123), Some("seed People 4".into())))
|
||||
rt.block_on(db1.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
|
||||
.expect("seed run 1");
|
||||
rt.block_on(db2.seed("People".into(), Some(4), Some(123), Some("seed People 4".into())))
|
||||
rt.block_on(db2.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
|
||||
.expect("seed run 2");
|
||||
|
||||
let csv1 = read_csv(&p1, "People").expect("csv 1");
|
||||
@@ -165,7 +319,7 @@ fn seed_writes_exactly_one_history_line() {
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
|
||||
rt.block_on(db.seed("People".into(), Some(5), Some(1), Some("seed People 5".into())))
|
||||
rt.block_on(db.seed("People".into(), None, Some(5), Vec::new(), Some(1), Some("seed People 5".into())))
|
||||
.expect("seed succeeds");
|
||||
|
||||
let history = std::fs::read_to_string(project.path().join("history.log"))
|
||||
@@ -240,10 +394,10 @@ fn seed_fills_foreign_keys_from_existing_parents() {
|
||||
create_users_and_orders(&db, &rt, true);
|
||||
|
||||
// 5 parents → serial ids 1..=5.
|
||||
rt.block_on(db.seed("Users".into(), Some(5), Some(1), Some("seed Users 5".into())))
|
||||
rt.block_on(db.seed("Users".into(), None, Some(5), Vec::new(), Some(1), Some("seed Users 5".into())))
|
||||
.expect("seed Users");
|
||||
let res = rt
|
||||
.block_on(db.seed("Orders".into(), Some(10), Some(2), Some("seed Orders 10".into())))
|
||||
.block_on(db.seed("Orders".into(), None, Some(10), Vec::new(), Some(2), Some("seed Orders 10".into())))
|
||||
.expect("seed Orders");
|
||||
assert_eq!(res.produced, 10, "every child row must insert (valid FK)");
|
||||
|
||||
@@ -267,7 +421,7 @@ fn seed_refuses_when_a_parent_table_is_empty() {
|
||||
|
||||
// Users is empty — no valid FK can be fabricated.
|
||||
let err = rt
|
||||
.block_on(db.seed("Orders".into(), Some(3), Some(1), Some("seed Orders 3".into())))
|
||||
.block_on(db.seed("Orders".into(), None, Some(3), Vec::new(), Some(1), Some("seed Orders 3".into())))
|
||||
.expect_err("seed must refuse an empty parent");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Users"), "error should name the empty parent: {msg}");
|
||||
@@ -293,7 +447,7 @@ fn seed_refuses_a_not_null_blob_column() {
|
||||
.expect("create Files");
|
||||
|
||||
let err = rt
|
||||
.block_on(db.seed("Files".into(), Some(2), Some(1), Some("seed Files 2".into())))
|
||||
.block_on(db.seed("Files".into(), None, Some(2), Vec::new(), Some(1), Some("seed Files 2".into())))
|
||||
.expect_err("seed must refuse a NOT NULL blob");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
@@ -320,7 +474,7 @@ fn seed_omits_a_nullable_blob_column() {
|
||||
.expect("create Files");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Files".into(), Some(3), Some(1), Some("seed Files 3".into())))
|
||||
.block_on(db.seed("Files".into(), None, Some(3), Vec::new(), Some(1), Some("seed Files 3".into())))
|
||||
.expect("seed succeeds despite the nullable blob");
|
||||
assert_eq!(res.produced, 3);
|
||||
let csv = read_csv(&project, "Files").expect("Files CSV");
|
||||
@@ -354,7 +508,7 @@ fn seed_keeps_unique_columns_distinct() {
|
||||
.expect("create Tags");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Tags".into(), Some(8), Some(3), Some("seed Tags 8".into())))
|
||||
.block_on(db.seed("Tags".into(), None, Some(8), Vec::new(), Some(3), Some("seed Tags 8".into())))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 8);
|
||||
|
||||
@@ -383,7 +537,7 @@ fn seed_sequences_identifier_int_columns() {
|
||||
.expect("create Items");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Items".into(), Some(5), Some(1), Some("seed Items 5".into())))
|
||||
.block_on(db.seed("Items".into(), None, Some(5), Vec::new(), Some(1), Some("seed Items 5".into())))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 5);
|
||||
|
||||
@@ -414,7 +568,7 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
|
||||
)
|
||||
.await
|
||||
.expect("create parent");
|
||||
db.seed(t.into(), Some(2), Some(1), Some(format!("seed {t} 2")))
|
||||
db.seed(t.into(), None, Some(2), Vec::new(), Some(1), Some(format!("seed {t} 2")))
|
||||
.await
|
||||
.expect("seed parent");
|
||||
}
|
||||
@@ -456,7 +610,7 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
|
||||
|
||||
// Requesting 10 caps at the 4 available distinct combinations.
|
||||
let res = db
|
||||
.seed("J".into(), Some(10), Some(7), Some("seed J 10".into()))
|
||||
.seed("J".into(), None, Some(10), Vec::new(), Some(7), Some("seed J 10".into()))
|
||||
.await
|
||||
.expect("seed J");
|
||||
assert_eq!(res.produced, 4, "junction caps at available combos");
|
||||
@@ -490,7 +644,7 @@ fn seed_draws_enum_values_from_an_in_check() {
|
||||
|
||||
// Every generated status must satisfy the CHECK, so all rows insert.
|
||||
let res = rt
|
||||
.block_on(db.seed("Tickets".into(), Some(12), Some(2), Some("seed Tickets 12".into())))
|
||||
.block_on(db.seed("Tickets".into(), None, Some(12), Vec::new(), Some(2), Some("seed Tickets 12".into())))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 12, "all rows insert — values satisfy the CHECK");
|
||||
|
||||
@@ -527,7 +681,7 @@ fn seed_advises_on_enum_ish_columns() {
|
||||
.expect("create Tasks");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Tasks".into(), Some(3), Some(1), Some("seed Tasks 3".into())))
|
||||
.block_on(db.seed("Tasks".into(), None, Some(3), Vec::new(), Some(1), Some("seed Tasks 3".into())))
|
||||
.expect("seed");
|
||||
assert!(
|
||||
res.advisory_columns.contains(&"status".to_string()),
|
||||
@@ -542,7 +696,7 @@ fn seed_refuses_an_excessive_count() {
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
let err = rt
|
||||
.block_on(db.seed("People".into(), Some(1_000_000), Some(1), Some("seed People 1000000".into())))
|
||||
.block_on(db.seed("People".into(), None, Some(1_000_000), Vec::new(), Some(1), Some("seed People 1000000".into())))
|
||||
.expect_err("an excessive count must be refused");
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("maximum"),
|
||||
@@ -557,7 +711,7 @@ fn seed_preview_is_capped_but_count_is_full() {
|
||||
create_people(&db, &rt);
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("People".into(), Some(25), Some(1), Some("seed People 25".into())))
|
||||
.block_on(db.seed("People".into(), None, Some(25), Vec::new(), Some(1), Some("seed People 25".into())))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 25, "the full count is produced");
|
||||
assert_eq!(res.data.rows.len(), 20, "the preview is capped at 20 rows");
|
||||
@@ -573,6 +727,25 @@ fn seed_is_available_in_advanced_mode() {
|
||||
matches!(r, Ok(Command::Seed { .. })),
|
||||
"seed must parse in advanced mode: {r:?}"
|
||||
);
|
||||
// The Phase 2 surfaces (set clause + column-fill) also parse in
|
||||
// advanced mode — same grammar, no mode gate.
|
||||
assert!(
|
||||
matches!(
|
||||
parse_command_in_mode("seed People 5 set status = 'active'", Mode::Advanced),
|
||||
Ok(Command::Seed { .. })
|
||||
),
|
||||
"set clause must parse in advanced mode"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
parse_command_in_mode("seed People.email set email as email", Mode::Advanced),
|
||||
Ok(Command::Seed {
|
||||
target_column: Some(_),
|
||||
..
|
||||
})
|
||||
),
|
||||
"column-fill must parse in advanced mode"
|
||||
);
|
||||
}
|
||||
|
||||
// — DA-pass coverage: undo (D15), replay (D16), atomicity, zero count,
|
||||
@@ -588,7 +761,7 @@ fn seed_is_one_undo_step() {
|
||||
.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())))
|
||||
rt.block_on(db.seed("People".into(), None, Some(6), Vec::new(), Some(1), Some("seed People 6".into())))
|
||||
.expect("seed");
|
||||
assert_eq!(data_row_count(&read_csv(&project, "People").unwrap()), 6);
|
||||
|
||||
@@ -598,6 +771,32 @@ fn seed_is_one_undo_step() {
|
||||
assert_eq!(rows, 0, "one undo must remove every seeded row in a single step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_is_one_undo_step() {
|
||||
// ADR-0048 D15: column-fill's bulk UPDATE is one undo step too.
|
||||
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_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 5 --seed 1").expect("seed");
|
||||
// Fill `status` across all 5 rows with a constant, then undo once.
|
||||
run_seed(&db, &rt, "seed Members.status set status = 'flagged' --seed 2")
|
||||
.expect("column-fill");
|
||||
let before = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
|
||||
assert!(before.iter().all(|s| s == "flagged"), "all rows filled: {before:?}");
|
||||
|
||||
rt.block_on(db.undo()).unwrap().expect("undo applied");
|
||||
let after = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
|
||||
assert!(
|
||||
after.iter().all(|s| s != "flagged"),
|
||||
"one undo reverts the whole column-fill in a single step: {after:?}"
|
||||
);
|
||||
assert_eq!(after.len(), 5, "undo restores the original rows, not removes them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_reruns_a_seed_line_as_a_data_write() {
|
||||
use rdbms_playground::runtime::run_replay;
|
||||
@@ -632,7 +831,7 @@ fn seed_rolls_back_atomically_on_a_constraint_failure() {
|
||||
))
|
||||
.expect("create Bad");
|
||||
|
||||
let res = rt.block_on(db.seed("Bad".into(), Some(5), Some(1), Some("seed Bad 5".into())));
|
||||
let res = rt.block_on(db.seed("Bad".into(), None, Some(5), Vec::new(), 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)");
|
||||
@@ -644,7 +843,7 @@ fn seed_zero_is_a_no_op() {
|
||||
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())))
|
||||
.block_on(db.seed("People".into(), None, Some(0), Vec::new(), 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));
|
||||
@@ -669,7 +868,7 @@ fn seed_advises_on_a_complex_check_column() {
|
||||
.expect("create Widgets");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Widgets".into(), Some(3), Some(1), Some("seed Widgets 3".into())))
|
||||
.block_on(db.seed("Widgets".into(), None, Some(3), Vec::new(), Some(1), Some("seed Widgets 3".into())))
|
||||
.expect("seed");
|
||||
assert!(
|
||||
res.advisory_columns.contains(&"label".to_string()),
|
||||
@@ -683,9 +882,9 @@ 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())))
|
||||
rt.block_on(db.seed("Users".into(), None, Some(4), Vec::new(), 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())))
|
||||
rt.block_on(db.seed("Orders".into(), None, Some(8), Vec::new(), Some(99), Some("seed Orders 8".into())))
|
||||
.expect("seed orders");
|
||||
};
|
||||
let (p1, db1, _d1) = open_project_db();
|
||||
@@ -715,7 +914,7 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
|
||||
None,
|
||||
))
|
||||
.expect("create Contacts");
|
||||
rt.block_on(db.seed("Contacts".into(), Some(5), Some(42), Some("seed Contacts 5".into())))
|
||||
rt.block_on(db.seed("Contacts".into(), None, Some(5), Vec::new(), Some(42), Some("seed Contacts 5".into())))
|
||||
.expect("seed");
|
||||
};
|
||||
let (p1, db1, _d1) = open_project_db();
|
||||
@@ -736,3 +935,343 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
|
||||
assert_eq!(code.len(), 10, "shortid should be 10 chars: {code}");
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Phase 2 (SD2) executor: set-clause overrides + column-fill,
|
||||
// exercised full-stack (parse → worker) — ADR-0048 D2 / D1.
|
||||
// =================================================================
|
||||
|
||||
/// Parse `input` as a `seed` command and run it through the worker —
|
||||
/// the full stack minus UI render (grammar → builder → executor).
|
||||
fn run_seed(
|
||||
db: &Database,
|
||||
rt: &tokio::runtime::Runtime,
|
||||
input: &str,
|
||||
) -> Result<rdbms_playground::db::SeedResult, rdbms_playground::db::DbError> {
|
||||
match parse_command(input).unwrap_or_else(|e| panic!("`{input}` should parse: {e:?}")) {
|
||||
Command::Seed {
|
||||
table,
|
||||
target_column,
|
||||
count,
|
||||
overrides,
|
||||
rng_seed,
|
||||
} => rt.block_on(db.seed(
|
||||
table,
|
||||
target_column,
|
||||
count,
|
||||
overrides,
|
||||
rng_seed,
|
||||
Some(input.to_string()),
|
||||
)),
|
||||
other => panic!("expected a seed command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Values of the column named `col` (by header lookup) across the CSV's
|
||||
/// data rows.
|
||||
fn named_column_values(csv: &str, col: &str) -> Vec<String> {
|
||||
let header = csv.lines().next().unwrap_or_default();
|
||||
let idx = header
|
||||
.split(',')
|
||||
.position(|h| h.trim() == col)
|
||||
.unwrap_or_else(|| panic!("column `{col}` not in header `{header}`"));
|
||||
nth_column_values(csv, idx)
|
||||
}
|
||||
|
||||
/// `Members(id serial pk, name text, status text, role text, age int)`.
|
||||
/// `status`/`role` are enum-ish names (advisory targets without an
|
||||
/// override); `name`/`age` exercise the generator / range overrides.
|
||||
fn create_members(db: &Database, rt: &tokio::runtime::Runtime) {
|
||||
rt.block_on(db.create_table(
|
||||
"Members".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
ColumnSpec::new("name", Type::Text),
|
||||
ColumnSpec::new("status", Type::Text),
|
||||
ColumnSpec::new("role", Type::Text),
|
||||
ColumnSpec::new("age", Type::Int),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
.expect("create Members");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_fixed_value_fills_every_row() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 6 set status = 'active' --seed 1").expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
let statuses = named_column_values(&csv, "status");
|
||||
assert_eq!(statuses.len(), 6);
|
||||
assert!(statuses.iter().all(|s| s == "active"), "every status pinned: {statuses:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_pick_list_draws_only_from_the_list() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 20 set role in ('admin', 'user') --seed 2").expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
let roles = named_column_values(&csv, "role");
|
||||
assert!(
|
||||
roles.iter().all(|r| r == "admin" || r == "user"),
|
||||
"roles only from the list: {roles:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_as_generator_forces_the_shape() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
// Force the `name` column (a person-name heuristic) to emails.
|
||||
run_seed(&db, &rt, "seed Members 5 set name as email --seed 3").expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
let names = named_column_values(&csv, "name");
|
||||
assert!(names.iter().all(|n| n.contains('@')), "name forced to email shape: {names:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_set_numeric_range_stays_within_bounds() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 30 set age between 30 and 40 --seed 4").expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
for a in named_column_values(&csv, "age") {
|
||||
let n: i64 = a.parse().unwrap_or_else(|_| panic!("age `{a}` not an int"));
|
||||
assert!((30..=40).contains(&n), "age {n} out of [30,40]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_override_drops_the_column_from_the_advisory() {
|
||||
let (_p, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
// Without an override, `status` (enum-ish) is flagged in the advisory.
|
||||
let plain = run_seed(&db, &rt, "seed Members 3 --seed 5").expect("seed");
|
||||
assert!(
|
||||
plain.advisory_columns.iter().any(|c| c == "status"),
|
||||
"status should be advised without an override: {:?}",
|
||||
plain.advisory_columns
|
||||
);
|
||||
// With an override on status, it must not appear in the advisory.
|
||||
let overridden =
|
||||
run_seed(&db, &rt, "seed Members 3 set status in ('a', 'b') --seed 5").expect("seed");
|
||||
assert!(
|
||||
!overridden.advisory_columns.iter().any(|c| c == "status"),
|
||||
"overridden status must drop from advisory: {:?}",
|
||||
overridden.advisory_columns
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_unknown_generator_is_a_friendly_error() {
|
||||
let (_p, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
let err = run_seed(&db, &rt, "seed Members 3 set name as bogus").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("unknown generator") && msg.contains("bogus"),
|
||||
"should name the unknown generator: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_incompatible_range_is_a_friendly_error() {
|
||||
let (_p, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
// A numeric range on a text column (`name`) is rejected.
|
||||
let err = run_seed(&db, &rt, "seed Members 3 set name between 1 and 10").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("between"), "range error should mention `between`: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_with_set_is_reproducible() {
|
||||
let (p1, db1, _d1) = open_project_db();
|
||||
let (p2, db2, _d2) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db1, &rt);
|
||||
create_members(&db2, &rt);
|
||||
let cmd = "seed Members 10 set role in ('a', 'b', 'c'), age between 20 and 60 --seed 77";
|
||||
run_seed(&db1, &rt, cmd).expect("seed 1");
|
||||
run_seed(&db2, &rt, cmd).expect("seed 2");
|
||||
assert_eq!(
|
||||
read_csv(&p1, "Members").unwrap(),
|
||||
read_csv(&p2, "Members").unwrap(),
|
||||
"the same --seed + set clause must reproduce identical data"
|
||||
);
|
||||
}
|
||||
|
||||
// — column-fill (ADR-0048 D1 form 2) —
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_updates_existing_rows_without_adding() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 5 --seed 1").expect("initial seed");
|
||||
let before = data_row_count(&read_csv(&project, "Members").unwrap());
|
||||
assert_eq!(before, 5);
|
||||
|
||||
let res = run_seed(&db, &rt, "seed Members.status set status in ('x', 'y') --seed 2")
|
||||
.expect("column-fill");
|
||||
assert_eq!(res.produced, 5, "column-fill touches the 5 existing rows");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
assert_eq!(data_row_count(&csv), 5, "no new rows added");
|
||||
let statuses = named_column_values(&csv, "status");
|
||||
assert!(
|
||||
statuses.iter().all(|s| s == "x" || s == "y"),
|
||||
"every existing row's status refilled from the list: {statuses:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_refuses_a_pk_target() {
|
||||
let (_p, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 3 --seed 1").expect("seed");
|
||||
let err = run_seed(&db, &rt, "seed Members.id").unwrap_err();
|
||||
assert!(format!("{err}").contains("primary key"), "PK target refused: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_empty_table_is_a_noop() {
|
||||
let (_p, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
// No rows yet → friendly no-op, not an error.
|
||||
let res = run_seed(&db, &rt, "seed Members.status set status in ('a', 'b')").expect("no-op");
|
||||
assert_eq!(res.produced, 0, "empty table → nothing filled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_set_may_only_target_the_filled_column() {
|
||||
let (_p, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 3 --seed 1").expect("seed");
|
||||
let err = run_seed(&db, &rt, "seed Members.status set role = 'x'").unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("can only adjust"),
|
||||
"set targeting another column is refused: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_rejects_a_row_count() {
|
||||
let (_p, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
// `seed T.col 5` parses, but a count is meaningless for column-fill.
|
||||
let err = rt
|
||||
.block_on(db.seed(
|
||||
"Members".into(),
|
||||
Some("status".into()),
|
||||
Some(5),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Members.status 5".into()),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("no row count"), "count refused: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_fk_target_samples_the_parent() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_users_and_orders(&db, &rt, true);
|
||||
run_seed(&db, &rt, "seed Users 4 --seed 1").expect("seed users");
|
||||
run_seed(&db, &rt, "seed Orders 8 --seed 2").expect("seed orders");
|
||||
// Re-fill the FK column across existing orders; every value must be a
|
||||
// valid parent key (the UPDATE would fail FK enforcement otherwise).
|
||||
let res = run_seed(&db, &rt, "seed Orders.user_id --seed 3").expect("column-fill FK");
|
||||
assert_eq!(res.produced, 8);
|
||||
let csv = read_csv(&project, "Orders").unwrap();
|
||||
let user_ids = named_column_values(&csv, "user_id");
|
||||
assert!(user_ids.iter().all(|v| (1..=4).contains(&v.parse::<i64>().unwrap())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
|
||||
// DA finding (user-chosen: friendly error). A fixed value can't fill a
|
||||
// UNIQUE column for more than one row — refuse up front rather than
|
||||
// silently capping to 1.
|
||||
let (_p, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
rt.block_on(db.create_table(
|
||||
"U".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
{
|
||||
let mut c = ColumnSpec::new("email", Type::Text);
|
||||
c.unique = true;
|
||||
c
|
||||
},
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
.expect("create U");
|
||||
let err = run_seed(&db, &rt, "seed U 5 set email = 'x@y.com'").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("UNIQUE") && msg.contains("distinct"),
|
||||
"fixed value on a UNIQUE column should be a friendly capacity error: {msg}"
|
||||
);
|
||||
// A short pick-list (< count) is likewise refused...
|
||||
let err2 = run_seed(&db, &rt, "seed U 5 set email in ('a@b.c', 'd@e.f')").unwrap_err();
|
||||
assert!(format!("{err2}").contains("distinct"), "short list refused: {err2}");
|
||||
// ...but a pick-list with enough distinct values succeeds.
|
||||
let ok = run_seed(
|
||||
&db,
|
||||
&rt,
|
||||
"seed U 3 set email in ('a@b.c', 'd@e.f', 'g@h.i') --seed 1",
|
||||
)
|
||||
.expect("a list >= count fills cleanly");
|
||||
assert_eq!(ok.produced, 3);
|
||||
// A generator is unbounded — also fine.
|
||||
assert_eq!(
|
||||
run_seed(&db, &rt, "seed U 4 set email as email --seed 2")
|
||||
.expect("generator fills a unique column")
|
||||
.produced,
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_fixed_on_unique_column_is_a_friendly_error() {
|
||||
let (_p, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
rt.block_on(db.create_table(
|
||||
"U".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
{
|
||||
let mut c = ColumnSpec::new("email", Type::Text);
|
||||
c.unique = true;
|
||||
c
|
||||
},
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
.expect("create U");
|
||||
run_seed(&db, &rt, "seed U 4 set email as email --seed 1").expect("seed 4 rows");
|
||||
// Filling the UNIQUE column on 4 rows with one fixed value is refused.
|
||||
let err = run_seed(&db, &rt, "seed U.email set email = 'same@x.com'").unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("UNIQUE"),
|
||||
"column-fill of a fixed value on a UNIQUE column should refuse: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user