fbd219b631
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.
739 lines
25 KiB
Rust
739 lines
25 KiB
Rust
//! Tier-3 integration tests for the `seed` command (ADR-0048, the
|
|
//! Phase-1 walking skeleton). Covers the parse path (grammar → AST),
|
|
//! the worker round-trip (rows generated + persisted to CSV),
|
|
//! reproducibility via a fixed `--seed`, and the single `history.log`
|
|
//! line for the whole command (ADR-0048 D15 / U3).
|
|
|
|
use rdbms_playground::db::Database;
|
|
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, Type, parse_command};
|
|
use rdbms_playground::persistence::Persistence;
|
|
use rdbms_playground::project;
|
|
|
|
fn rt() -> tokio::runtime::Runtime {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("tokio rt")
|
|
}
|
|
|
|
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().expect("create tempdir");
|
|
let project =
|
|
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
|
let persistence = Persistence::new(project.path().to_path_buf());
|
|
let db = Database::open_with_persistence(project.db_path(), persistence)
|
|
.expect("open db with persistence");
|
|
(project, db, dir)
|
|
}
|
|
|
|
fn read_csv(project: &project::Project, table: &str) -> Option<String> {
|
|
std::fs::read_to_string(project.path().join("data").join(format!("{table}.csv"))).ok()
|
|
}
|
|
|
|
/// `People(id serial pk, name text, email text)` — `id` is autogen
|
|
/// (excluded from generation, so no PK collisions), `name`/`email`
|
|
/// are generated.
|
|
fn create_people(db: &Database, rt: &tokio::runtime::Runtime) {
|
|
rt.block_on(db.create_table(
|
|
"People".to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("name", Type::Text),
|
|
ColumnSpec::new("email", Type::Text),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
))
|
|
.expect("create People");
|
|
}
|
|
|
|
/// Data rows in a CSV = non-empty lines minus the header.
|
|
fn data_row_count(csv: &str) -> usize {
|
|
csv.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.count()
|
|
.saturating_sub(1)
|
|
}
|
|
|
|
#[test]
|
|
fn seed_parses_with_and_without_count() {
|
|
match parse_command("seed People 5").expect("`seed People 5` parses") {
|
|
Command::Seed {
|
|
table,
|
|
count,
|
|
rng_seed,
|
|
} => {
|
|
assert_eq!(table, "People");
|
|
assert_eq!(count, Some(5));
|
|
assert_eq!(rng_seed, None);
|
|
}
|
|
other => panic!("expected Command::Seed, got {other:?}"),
|
|
}
|
|
match parse_command("seed People").expect("`seed People` parses") {
|
|
Command::Seed { table, count, .. } => {
|
|
assert_eq!(table, "People");
|
|
assert_eq!(count, None, "omitted count is None (executor defaults to 20)");
|
|
}
|
|
other => panic!("expected Command::Seed, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[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();
|
|
let rt = rt();
|
|
create_people(&db, &rt);
|
|
|
|
let result = rt
|
|
.block_on(db.seed("People".into(), Some(7), Some(42), Some("seed People 7".into())))
|
|
.expect("seed succeeds");
|
|
assert_eq!(result.produced, 7);
|
|
|
|
let csv = read_csv(&project, "People").expect("People CSV exists after seed");
|
|
assert_eq!(
|
|
data_row_count(&csv),
|
|
7,
|
|
"CSV should hold 7 generated rows:\n{csv}"
|
|
);
|
|
// The generated `email` column produces address-shaped values.
|
|
assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}");
|
|
}
|
|
|
|
#[test]
|
|
fn seed_count_defaults_to_twenty() {
|
|
let (project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
create_people(&db, &rt);
|
|
|
|
let result = rt
|
|
.block_on(db.seed("People".into(), None, 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");
|
|
assert_eq!(data_row_count(&csv), 20);
|
|
}
|
|
|
|
#[test]
|
|
fn seed_is_reproducible_with_a_fixed_seed() {
|
|
let (p1, db1, _d1) = open_project_db();
|
|
let (p2, db2, _d2) = open_project_db();
|
|
let rt = rt();
|
|
create_people(&db1, &rt);
|
|
create_people(&db2, &rt);
|
|
|
|
rt.block_on(db1.seed("People".into(), Some(4), 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())))
|
|
.expect("seed run 2");
|
|
|
|
let csv1 = read_csv(&p1, "People").expect("csv 1");
|
|
let csv2 = read_csv(&p2, "People").expect("csv 2");
|
|
assert_eq!(csv1, csv2, "the same --seed must reproduce identical data");
|
|
}
|
|
|
|
#[test]
|
|
fn seed_writes_exactly_one_history_line() {
|
|
let (project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
create_people(&db, &rt);
|
|
|
|
rt.block_on(db.seed("People".into(), Some(5), Some(1), Some("seed People 5".into())))
|
|
.expect("seed succeeds");
|
|
|
|
let history = std::fs::read_to_string(project.path().join("history.log"))
|
|
.expect("history.log exists");
|
|
let seed_lines = history.lines().filter(|l| l.contains("seed People 5")).count();
|
|
assert_eq!(
|
|
seed_lines, 1,
|
|
"a seed of 5 rows must write exactly one history line:\n{history}"
|
|
);
|
|
}
|
|
|
|
// — FK sampling, empty-parent error, block guard (ADR-0048 D14 / D1) —
|
|
|
|
/// `Users(id serial pk, name text)` + `Orders(id serial pk, user_id
|
|
/// int, total decimal)` with `Orders.user_id -> Users.id`.
|
|
fn create_users_and_orders(db: &Database, rt: &tokio::runtime::Runtime, add_fk: bool) {
|
|
rt.block_on(async {
|
|
db.create_table(
|
|
"Users".to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("name", Type::Text),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create Users");
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("user_id", Type::Int),
|
|
ColumnSpec::new("total", Type::Decimal),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create Orders");
|
|
if add_fk {
|
|
db.add_relationship(
|
|
None,
|
|
"Users".to_string(),
|
|
vec!["id".to_string()],
|
|
"Orders".to_string(),
|
|
vec!["user_id".to_string()],
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("add Orders->Users FK");
|
|
}
|
|
});
|
|
}
|
|
|
|
/// `user_id` is column index 1 of `Orders(id, user_id, total)`.
|
|
fn order_user_ids(csv: &str) -> Vec<String> {
|
|
let mut lines = csv.lines().filter(|l| !l.trim().is_empty());
|
|
lines.next(); // header
|
|
lines
|
|
.map(|l| l.split(',').nth(1).unwrap_or_default().to_string())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn seed_fills_foreign_keys_from_existing_parents() {
|
|
let (project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
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())))
|
|
.expect("seed Users");
|
|
let res = rt
|
|
.block_on(db.seed("Orders".into(), Some(10), Some(2), Some("seed Orders 10".into())))
|
|
.expect("seed Orders");
|
|
assert_eq!(res.produced, 10, "every child row must insert (valid FK)");
|
|
|
|
let csv = read_csv(&project, "Orders").expect("Orders CSV");
|
|
let valid: std::collections::HashSet<String> = (1..=5).map(|i| i.to_string()).collect();
|
|
let user_ids = order_user_ids(&csv);
|
|
assert_eq!(user_ids.len(), 10);
|
|
for uid in &user_ids {
|
|
assert!(
|
|
valid.contains(uid),
|
|
"user_id `{uid}` does not reference an existing parent:\n{csv}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn seed_refuses_when_a_parent_table_is_empty() {
|
|
let (_project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
create_users_and_orders(&db, &rt, true);
|
|
|
|
// 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())))
|
|
.expect_err("seed must refuse an empty parent");
|
|
let msg = err.to_string();
|
|
assert!(msg.contains("Users"), "error should name the empty parent: {msg}");
|
|
let lower = msg.to_lowercase();
|
|
assert!(
|
|
lower.contains("no rows") || lower.contains("first"),
|
|
"error should explain how to fix it: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn seed_refuses_a_not_null_blob_column() {
|
|
let (_project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
let mut payload = ColumnSpec::new("payload", Type::Blob);
|
|
payload.not_null = true;
|
|
rt.block_on(db.create_table(
|
|
"Files".to_string(),
|
|
vec![ColumnSpec::new("id", Type::Serial), payload],
|
|
vec!["id".to_string()],
|
|
None,
|
|
))
|
|
.expect("create Files");
|
|
|
|
let err = rt
|
|
.block_on(db.seed("Files".into(), Some(2), Some(1), Some("seed Files 2".into())))
|
|
.expect_err("seed must refuse a NOT NULL blob");
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("payload") && msg.to_lowercase().contains("blob"),
|
|
"error should name the un-generatable blob column: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn seed_omits_a_nullable_blob_column() {
|
|
let (project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(db.create_table(
|
|
"Files".to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("name", Type::Text),
|
|
// nullable blob → omitted (→ NULL), seed still succeeds.
|
|
ColumnSpec::new("payload", Type::Blob),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
))
|
|
.expect("create Files");
|
|
|
|
let res = rt
|
|
.block_on(db.seed("Files".into(), Some(3), 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");
|
|
assert_eq!(data_row_count(&csv), 3);
|
|
}
|
|
|
|
// — uniqueness, junction distinct-combos, IN-CHECK (D10 / D14 / D17) —
|
|
|
|
/// The `n`th comma-separated field of each data row (the generated
|
|
/// values here never contain commas).
|
|
fn nth_column_values(csv: &str, n: usize) -> Vec<String> {
|
|
csv.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.skip(1)
|
|
.map(|l| l.split(',').nth(n).unwrap_or_default().trim().to_string())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn seed_keeps_unique_columns_distinct() {
|
|
let (project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
let mut label = ColumnSpec::new("label", Type::Text);
|
|
label.unique = true;
|
|
rt.block_on(db.create_table(
|
|
"Tags".to_string(),
|
|
vec![ColumnSpec::new("id", Type::Serial), label],
|
|
vec!["id".to_string()],
|
|
None,
|
|
))
|
|
.expect("create Tags");
|
|
|
|
let res = rt
|
|
.block_on(db.seed("Tags".into(), Some(8), Some(3), Some("seed Tags 8".into())))
|
|
.expect("seed");
|
|
assert_eq!(res.produced, 8);
|
|
|
|
let csv = read_csv(&project, "Tags").expect("Tags CSV");
|
|
let labels = nth_column_values(&csv, 1);
|
|
let distinct: std::collections::HashSet<&String> = labels.iter().collect();
|
|
assert_eq!(distinct.len(), labels.len(), "UNIQUE column has duplicates:\n{csv}");
|
|
}
|
|
|
|
#[test]
|
|
fn seed_sequences_identifier_int_columns() {
|
|
let (project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
// `code` is an identifier-named int (D10) but not a constraint —
|
|
// uniqueness comes from the identifier rule.
|
|
rt.block_on(db.create_table(
|
|
"Items".to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("code", Type::Int),
|
|
ColumnSpec::new("name", Type::Text),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
))
|
|
.expect("create Items");
|
|
|
|
let res = rt
|
|
.block_on(db.seed("Items".into(), Some(5), Some(1), Some("seed Items 5".into())))
|
|
.expect("seed");
|
|
assert_eq!(res.produced, 5);
|
|
|
|
let csv = read_csv(&project, "Items").expect("Items CSV");
|
|
let codes: Vec<i64> = nth_column_values(&csv, 1)
|
|
.iter()
|
|
.map(|s| s.parse().expect("code is an int"))
|
|
.collect();
|
|
let distinct: std::collections::HashSet<i64> = codes.iter().copied().collect();
|
|
assert_eq!(distinct.len(), 5, "identifier ints must be unique: {codes:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn seed_junction_produces_distinct_combinations_and_caps() {
|
|
let (project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(async {
|
|
// Two parents, 2 rows each → 2x2 = 4 possible (a, b) pairs.
|
|
for t in ["P1", "P2"] {
|
|
db.create_table(
|
|
t.to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("name", Type::Text),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create parent");
|
|
db.seed(t.into(), Some(2), Some(1), Some(format!("seed {t} 2")))
|
|
.await
|
|
.expect("seed parent");
|
|
}
|
|
// Junction with a compound PK over its two FK columns.
|
|
db.create_table(
|
|
"J".to_string(),
|
|
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
|
|
vec!["a".to_string(), "b".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create J");
|
|
db.add_relationship(
|
|
None,
|
|
"P1".into(),
|
|
vec!["id".into()],
|
|
"J".into(),
|
|
vec!["a".into()],
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("fk a");
|
|
db.add_relationship(
|
|
None,
|
|
"P2".into(),
|
|
vec!["id".into()],
|
|
"J".into(),
|
|
vec!["b".into()],
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("fk b");
|
|
|
|
// Requesting 10 caps at the 4 available distinct combinations.
|
|
let res = db
|
|
.seed("J".into(), Some(10), Some(7), Some("seed J 10".into()))
|
|
.await
|
|
.expect("seed J");
|
|
assert_eq!(res.produced, 4, "junction caps at available combos");
|
|
assert_eq!(res.requested, 10, "the requested count is reported for the cap note");
|
|
});
|
|
|
|
let csv = read_csv(&project, "J").expect("J CSV");
|
|
let pairs: Vec<String> = csv
|
|
.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.skip(1)
|
|
.map(str::to_string)
|
|
.collect();
|
|
let distinct: std::collections::HashSet<&String> = pairs.iter().collect();
|
|
assert_eq!(distinct.len(), pairs.len(), "junction rows must be distinct:\n{csv}");
|
|
}
|
|
|
|
#[test]
|
|
fn seed_draws_enum_values_from_an_in_check() {
|
|
let (project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
let mut status = ColumnSpec::new("status", Type::Text);
|
|
status.check_sql = Some("status IN ('active', 'closed')".to_string());
|
|
rt.block_on(db.create_table(
|
|
"Tickets".to_string(),
|
|
vec![ColumnSpec::new("id", Type::Serial), status],
|
|
vec!["id".to_string()],
|
|
None,
|
|
))
|
|
.expect("create Tickets");
|
|
|
|
// 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())))
|
|
.expect("seed");
|
|
assert_eq!(res.produced, 12, "all rows insert — values satisfy the CHECK");
|
|
|
|
let csv = read_csv(&project, "Tickets").expect("Tickets CSV");
|
|
for v in nth_column_values(&csv, 1) {
|
|
assert!(
|
|
matches!(v.as_str(), "active" | "closed"),
|
|
"status `{v}` was not drawn from the IN check:\n{csv}"
|
|
);
|
|
}
|
|
// The IN-check column is derived, not generic, so it is NOT flagged.
|
|
assert!(
|
|
res.advisory_columns.is_empty(),
|
|
"an IN-check column should not be flagged: {:?}",
|
|
res.advisory_columns
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn seed_advises_on_enum_ish_columns() {
|
|
let (_project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
// `status` has no CHECK and no name heuristic → generic text, so it
|
|
// is flagged for the advisory (D12/D13).
|
|
rt.block_on(db.create_table(
|
|
"Tasks".to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("status", Type::Text),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
))
|
|
.expect("create Tasks");
|
|
|
|
let res = rt
|
|
.block_on(db.seed("Tasks".into(), Some(3), Some(1), Some("seed Tasks 3".into())))
|
|
.expect("seed");
|
|
assert!(
|
|
res.advisory_columns.contains(&"status".to_string()),
|
|
"enum-ish `status` should be flagged: {:?}",
|
|
res.advisory_columns
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn seed_refuses_an_excessive_count() {
|
|
let (_project, db, _dir) = open_project_db();
|
|
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())))
|
|
.expect_err("an excessive count must be refused");
|
|
assert!(
|
|
err.to_string().to_lowercase().contains("maximum"),
|
|
"error should mention the maximum: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn seed_preview_is_capped_but_count_is_full() {
|
|
let (_project, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
create_people(&db, &rt);
|
|
|
|
let res = rt
|
|
.block_on(db.seed("People".into(), Some(25), 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");
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
}
|