diff --git a/src/app.rs b/src/app.rs index e7c7e12..1106a85 100644 --- a/src/app.rs +++ b/src/app.rs @@ -771,6 +771,10 @@ impl App { self.handle_dsl_insert_success(&command, &result); Vec::new() } + AppEvent::DslSeedSucceeded { command, result } => { + self.handle_dsl_seed_success(&command, &result); + Vec::new() + } AppEvent::DslUpdateSucceeded { command, result, @@ -2072,6 +2076,34 @@ impl App { } } + /// Render a successful `seed` (ADR-0048): the ✓ echo, the seeded-row + /// count (with a cap note when the unique-value space ran out), the + /// capped preview table (D18), and a Hint-styled advisory naming + /// columns filled with generic text that look like fixed value sets + /// (D12/D13). + fn handle_dsl_seed_success(&mut self, command: &Command, result: &crate::db::SeedResult) { + self.note_ok_summary(command); + let mut summary = crate::t!( + "ok.rows_seeded", + count = result.produced, + table = result.table + ); + if result.produced < result.requested { + summary.push(' '); + summary.push_str(&crate::t!("seed.capped", requested = result.requested)); + } + self.note_system(summary); + for line in crate::output_render::render_data_table(&result.data) { + self.note_system(line); + } + if !result.advisory_columns.is_empty() { + self.push_category_three_prose(crate::t!( + "seed.advisory_generic", + columns = result.advisory_columns.join(", ") + )); + } + } + fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) { self.note_ok_summary(command); self.note_system(crate::t!("ok.rows_updated", count = result.rows_affected)); diff --git a/src/db.rs b/src/db.rs index 038cce9..3cf658b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -287,6 +287,23 @@ pub struct InsertResult { pub data: DataResult, } +/// Outcome of a successful `seed` (ADR-0048). +/// +/// `produced` is below `requested` when the unique-value space ran out +/// (D14 cap). `data` is a **capped preview** of the seeded rows (D18, +/// not the whole batch). `advisory_columns` names columns that were +/// filled with generic text but look like fixed value sets — enum-ish +/// names or un-derivable CHECKs (D12/D13) — so the render can nudge the +/// user toward choosing those values deliberately. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SeedResult { + pub table: String, + pub requested: u64, + pub produced: u64, + pub data: DataResult, + pub advisory_columns: Vec, +} + /// Outcome of a successful `add column …`. /// /// Carries the post-add structure (used for the auto-show that @@ -709,7 +726,7 @@ enum Request { count: Option, rng_seed: Option, source: Option, - reply: oneshot::Sender>, + reply: oneshot::Sender>, }, Update { table: String, @@ -1507,7 +1524,7 @@ impl Database { count: Option, rng_seed: Option, source: Option, - ) -> Result { + ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::Seed { table, @@ -8686,6 +8703,14 @@ fn count_rows(conn: &Connection, table: &str) -> Result { /// Default row count when `seed ` omits the count (ADR-0048 D6). const DEFAULT_SEED_COUNT: u64 = 20; +/// Upper bound on a single `seed` (ADR-0048 D6) — a typo like +/// `seed t 1000000` is refused rather than left to hang the app. +const MAX_SEED_COUNT: u64 = 10_000; + +/// Cap on rows shown in the post-seed auto-show preview (ADR-0048 D18). +/// The full count is always reported; only the rendered table is capped. +const SEED_PREVIEW_CAP: usize = 20; + /// How a single column's value is produced for each seeded row. enum SeedColPlan { /// Generated from the seed library (the generator is chosen once; @@ -8802,7 +8827,7 @@ fn do_seed( table: &str, count: Option, rng_seed: Option, -) -> Result { +) -> Result { use crate::seed; use rand::RngExt; @@ -8810,6 +8835,12 @@ fn do_seed( let table = canonical_table.as_str(); let n = count.unwrap_or(DEFAULT_SEED_COUNT); debug!(table = %table, count = n, "seed"); + if n > MAX_SEED_COUNT { + return Err(DbError::Unsupported(format!( + "cannot seed {n} rows at once: the maximum is {MAX_SEED_COUNT}. \ + Seed in smaller batches." + ))); + } let schema = read_schema(conn, table)?; @@ -8839,9 +8870,11 @@ fn do_seed( } // Build the per-column generation plan, skipping autogen and - // un-generatable columns. + // un-generatable columns. `advisory_columns` collects columns + // filled with generic text that look like fixed value sets (D12/D13). let mut col_names: Vec = Vec::new(); let mut plans: Vec = Vec::new(); + let mut advisory_columns: Vec = Vec::new(); for c in &schema.columns { let ty = c.user_type.unwrap_or(Type::Text); // serial/shortid auto-fill in `do_insert`; omit them. @@ -8879,6 +8912,15 @@ fn do_seed( check_in_values, }; let generator = seed::choose_generator(table, &spec); + // Flag columns that fell through to generic text but look + // like a fixed value set (enum-ish name, or a CHECK we + // could not derive values from) — D12/D13. + if matches!(generator, crate::seed::Generator::Generic) + && (seed::is_enum_ish(&c.name) + || (c.check.is_some() && spec.check_in_values.is_none())) + { + advisory_columns.push(c.name.clone()); + } plans.push(SeedColPlan::Generated { generator, ty }); } } @@ -8957,8 +8999,12 @@ fn do_seed( const MAX_ATTEMPTS: u32 = 200; let mut rng = seed::make_rng(rng_seed); - let mut rows_affected = 0usize; - let mut last_data: Option = None; + let mut preview = DataResult { + table_name: table.to_string(), + columns: Vec::new(), + column_types: Vec::new(), + rows: Vec::new(), + }; let mut accepted: u64 = 0; let mut capped = false; @@ -9024,8 +9070,15 @@ fn do_seed( }; match inserted { Some(result) => { - rows_affected += result.rows_affected; - last_data = Some(result.data); + // Accumulate the capped preview (D18). + if preview.columns.is_empty() { + preview.columns = result.data.columns; + preview.column_types = result.data.column_types; + } + if preview.rows.len() < SEED_PREVIEW_CAP { + preview.rows.extend(result.data.rows); + preview.rows.truncate(SEED_PREVIEW_CAP); + } accepted += 1; } None => break, @@ -9037,21 +9090,16 @@ fn do_seed( table = %table, requested = n, produced = accepted, - "seed capped: ran out of distinct unique-value combinations before the \ - requested count (user-facing note arrives with the advisory in P1.3c)" + "seed capped: ran out of distinct unique-value combinations before the requested count" ); } - Ok(InsertResult { - rows_affected, - // `None` only when count was 0 — an empty result for the - // auto-show (the zero-no-op refinement lands in a later phase). - data: last_data.unwrap_or_else(|| DataResult { - table_name: table.to_string(), - columns: Vec::new(), - column_types: Vec::new(), - rows: Vec::new(), - }), + Ok(SeedResult { + table: table.to_string(), + requested: n, + produced: accepted, + data: preview, + advisory_columns, }) } diff --git a/src/event.rs b/src/event.rs index 51b2be2..623f299 100644 --- a/src/event.rs +++ b/src/event.rs @@ -87,6 +87,10 @@ pub enum AppEvent { command: Command, result: InsertResult, }, + DslSeedSucceeded { + command: Command, + result: crate::db::SeedResult, + }, DslUpdateSucceeded { command: Command, result: UpdateResult, diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index d2a97f5..cb950ee 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -550,7 +550,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("ok.index_dropped_with_column", &["index"]), ("ok.rows_deleted", &["count"]), ("ok.rows_inserted", &["count"]), + ("ok.rows_seeded", &["count", "table"]), ("ok.rows_updated", &["count"]), + ("seed.capped", &["requested"]), + ("seed.advisory_generic", &["columns"]), // ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ---- ("client_side.auto_fill_add_serial", &["count"]), ("client_side.auto_fill_add_shortid", &["count"]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 53bb040..883e06a 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -983,6 +983,13 @@ db: # template couldn't provide. Re-introduce a key here if a non-English # locale lands.) +# Seed-command notes (ADR-0048): the cap note when the unique-value +# space is exhausted, and the advisory that flags columns filled with +# generic text that look like fixed value sets. +seed: + capped: "(of {requested} requested — ran out of distinct value combinations)" + advisory_generic: "{columns} filled with generic text — they look like fixed value sets." + ok: # ADR-0040: the generic `[ok] ` summary line was # retired — a successful command's echo line now carries a ✓ @@ -990,6 +997,7 @@ ok: # per-operation row-count footers below still convey real payload # and are unchanged. rows_inserted: " {count} row(s) inserted" + rows_seeded: " {count} row(s) seeded into {table}" rows_updated: " {count} row(s) updated" rows_deleted: " {count} row(s) deleted" # Shown beneath a `drop column --cascade` summary, once per diff --git a/src/runtime.rs b/src/runtime.rs index d0f873d..ce86895 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1492,6 +1492,10 @@ fn spawn_dsl_dispatch( command: command.clone(), result, }, + Ok(CommandOutcome::Seed(result)) => AppEvent::DslSeedSucceeded { + command: command.clone(), + result, + }, Ok(CommandOutcome::Update(result)) => AppEvent::DslUpdateSucceeded { command: command.clone(), result, @@ -2364,6 +2368,7 @@ enum CommandOutcome { ShowRelationship(Option>), QueryPlan(QueryPlan), Insert(InsertResult), + Seed(crate::db::SeedResult), Update(UpdateResult), Delete(DeleteResult), ChangeColumn(ChangeColumnTypeResult), @@ -2911,9 +2916,7 @@ async fn execute_command_typed( .insert(table, columns, values, src) .await .map(CommandOutcome::Insert), - // ADR-0048 (SD1). Phase 1 reuses the insert outcome for the - // auto-show; a dedicated `SeedResult` (capped preview + - // enum/CHECK advisory) replaces this in a later phase. + // ADR-0048 (SD1). Command::Seed { table, count, @@ -2921,7 +2924,7 @@ async fn execute_command_typed( } => database .seed(table, count, rng_seed, src) .await - .map(CommandOutcome::Insert), + .map(CommandOutcome::Seed), Command::Update { table, assignments, diff --git a/tests/it/seed.rs b/tests/it/seed.rs index 4984dac..0bcd1cf 100644 --- a/tests/it/seed.rs +++ b/tests/it/seed.rs @@ -87,7 +87,7 @@ fn seed_populates_a_table_and_persists_rows() { 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); + assert_eq!(result.produced, 7); let csv = read_csv(&project, "People").expect("People CSV exists after seed"); assert_eq!( @@ -108,7 +108,7 @@ fn seed_count_defaults_to_twenty() { 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"); + 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); } @@ -217,7 +217,7 @@ fn seed_fills_foreign_keys_from_existing_parents() { 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)"); + 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 = (1..=5).map(|i| i.to_string()).collect(); @@ -294,7 +294,7 @@ fn seed_omits_a_nullable_blob_column() { 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); + assert_eq!(res.produced, 3); let csv = read_csv(&project, "Files").expect("Files CSV"); assert_eq!(data_row_count(&csv), 3); } @@ -328,7 +328,7 @@ fn seed_keeps_unique_columns_distinct() { 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); + assert_eq!(res.produced, 8); let csv = read_csv(&project, "Tags").expect("Tags CSV"); let labels = nth_column_values(&csv, 1); @@ -357,7 +357,7 @@ fn seed_sequences_identifier_int_columns() { 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); + assert_eq!(res.produced, 5); let csv = read_csv(&project, "Items").expect("Items CSV"); let codes: Vec = nth_column_values(&csv, 1) @@ -431,7 +431,8 @@ fn seed_junction_produces_distinct_combinations_and_caps() { .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"); + 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"); @@ -463,7 +464,7 @@ fn seed_draws_enum_values_from_an_in_check() { 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"); + 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) { @@ -472,4 +473,64 @@ fn seed_draws_enum_values_from_an_in_check() { "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"); }