feat(seed): FK sampling, empty-parent error, block guard (ADR-0048 P1.3a)
do_seed fills foreign-key columns by sampling existing parent rows (D14): sample_parent_key_tuples reads distinct parent keys, and a compound FK reads all its child columns from one sampled parent row per child row. An empty parent is refused with a friendly "seed the parent first" error. The block guard (D1) refuses a NOT NULL blob column (seed can't generate one); a nullable blob is omitted (-> NULL). 4 integration tests (valid FK references, empty-parent refusal, NOT NULL blob refusal, nullable-blob omission). 2331 pass / 0 fail / 0 skip, clippy all-targets clean. Deferred to P1.3b: identifier/constraint uniqueness incl. junction distinct-combos (D10), IN-CHECK derivation (D17), dedicated SeedResult + capped preview (D18) + advisory (D12/D13), and the multi-row path.
This commit is contained in:
+151
-1
@@ -5,7 +5,7 @@
|
||||
//! line for the whole command (ADR-0048 D15 / U3).
|
||||
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, Type, parse_command};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, Type, parse_command};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project;
|
||||
|
||||
@@ -148,3 +148,153 @@ fn seed_writes_exactly_one_history_line() {
|
||||
"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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user