Files
rdbms-playground/tests/it/seed.rs
T
claude@clouddev1 9c135010ba feat(seed): uniqueness, junction distinct-combos, IN-CHECK (ADR-0048 P1.3b)
do_seed now enforces value uniqueness and derives enum values:
- Uniqueness groups (D10): the user-fillable PK, compound UNIQUE
  constraints, and single-column UNIQUE / identifier columns stay
  distinct across the batch and against existing rows (retry per row).
  Junction distinct-combos fall out of PK-tuple uniqueness and cap at
  the available parent combinations (logged when capped; the
  user-facing note arrives with the advisory in P1.3c).
- Identifier-int columns get a monotonic sequence past MAX(col) (D10),
  so they never collide.
- IN-CHECK derivation (D17): a simple `col IN ('a','b')` CHECK becomes
  the value source via the new, unit-tested seed::parse_in_check_values,
  so the enum-as-CHECK pattern just works.

8 parser unit tests + 4 integration tests (unique column, identifier
sequencing, junction cap, IN-check enum). 2343 pass / 0 fail / 0 skip,
clippy all-targets clean.

Deferred to P1.3c: dedicated SeedResult + capped preview (D18) + the
enum/CHECK advisory incl. the cap note (D12/D13); P1.3d: multi-row path.
2026-06-11 18:50:05 +00:00

476 lines
16 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_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.rows_affected, 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.rows_affected, 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.rows_affected, 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.rows_affected, 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.rows_affected, 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.rows_affected, 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.rows_affected, 4, "junction caps at available combos");
});
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.rows_affected, 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}"
);
}
}