feat(seed): command plumbing + walking skeleton (ADR-0048 P1.2)
End-to-end `seed <table> [count]` path, both modes: - Command::Seed AST + grammar node (show-data table slot + optional positional count) + REGISTRY registration + build_seed. - Runtime dispatch -> Database::seed -> Request::Seed worker arm -> do_seed. - do_seed (Phase-1 skeleton): generates whole rows for non-FK, non-autogen columns via the seed library and inserts them one at a time through do_insert (reusing validation / autogen autofill / FK-error / persistence). One undo step (snapshot_then wraps it) and one history.log line (only the first row carries the source); default count 20. - help (`help seed`) + parse-usage catalog entries. - Reuses CommandOutcome::Insert for the auto-show; a dedicated SeedResult (capped preview + advisory) replaces it in P1.3. 5 Tier-3 integration tests (parse, populate+persist, default-20, reproducible --seed, one history line). 2327 pass / 0 fail / 0 skip, clippy all-targets clean. Deferred to P1.3: FK sampling, identifier/constraint uniqueness, CHECK derivation, block guard, capped preview, advisory, multi-row path. Deferred to P1.4: completion/highlight/hint/validity wiring + --seed flag.
This commit is contained in:
@@ -2390,6 +2390,9 @@ impl App {
|
||||
// the executor), like the named DSL drop.
|
||||
C::SqlDropIndex { .. } => (Operation::DropIndex, None, None),
|
||||
C::Insert { table, .. } => (Operation::Insert, Some(table.as_str()), None),
|
||||
// Seed generates inserts; FK/constraint failures read as
|
||||
// insert errors (ADR-0048).
|
||||
C::Seed { table, .. } => (Operation::Insert, Some(table.as_str()), None),
|
||||
C::Update { table, .. } => (Operation::Update, Some(table.as_str()), None),
|
||||
C::Delete { table, .. } => (Operation::Delete, Some(table.as_str()), None),
|
||||
C::ShowData { name, .. } | C::ShowTable { name } => {
|
||||
|
||||
@@ -702,6 +702,15 @@ enum Request {
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<InsertResult, DbError>>,
|
||||
},
|
||||
/// Populate a table with generated fake data (ADR-0048). One undo
|
||||
/// snapshot wraps the whole seed via `snapshot_then`.
|
||||
Seed {
|
||||
table: String,
|
||||
count: Option<u64>,
|
||||
rng_seed: Option<u64>,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<InsertResult, DbError>>,
|
||||
},
|
||||
Update {
|
||||
table: String,
|
||||
assignments: Vec<(String, Value)>,
|
||||
@@ -1491,6 +1500,26 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Populate a table with generated fake data (ADR-0048, SD1).
|
||||
pub async fn seed(
|
||||
&self,
|
||||
table: String,
|
||||
count: Option<u64>,
|
||||
rng_seed: Option<u64>,
|
||||
source: Option<String>,
|
||||
) -> Result<InsertResult, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::Seed {
|
||||
table,
|
||||
count,
|
||||
rng_seed,
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
table: String,
|
||||
@@ -2646,6 +2675,24 @@ fn handle_request(
|
||||
&values,
|
||||
));
|
||||
}
|
||||
Request::Seed {
|
||||
table,
|
||||
count,
|
||||
rng_seed,
|
||||
source,
|
||||
reply,
|
||||
} => {
|
||||
// One snapshot wraps the whole seed (ADR-0048 D15 — one undo
|
||||
// step), exactly like a single insert.
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_seed(
|
||||
conn,
|
||||
persistence,
|
||||
source.as_deref(),
|
||||
&table,
|
||||
count,
|
||||
rng_seed,
|
||||
));
|
||||
}
|
||||
Request::Update {
|
||||
table,
|
||||
assignments,
|
||||
@@ -8636,6 +8683,108 @@ fn count_rows(conn: &Connection, table: &str) -> Result<i64, DbError> {
|
||||
.map_err(DbError::from_rusqlite)
|
||||
}
|
||||
|
||||
/// Default row count when `seed <T>` omits the count (ADR-0048 D6).
|
||||
const DEFAULT_SEED_COUNT: u64 = 20;
|
||||
|
||||
/// Populate a table with generated fake data (ADR-0048, SD1).
|
||||
///
|
||||
/// **Phase 1 walking skeleton.** Generates whole rows for every user
|
||||
/// column that is not an autogen `serial`/`shortid` and not a foreign
|
||||
/// key, inserting them one at a time through [`do_insert`] — which
|
||||
/// reuses all the existing per-value validation, autogen autofill,
|
||||
/// FK-error enrichment and persistence machinery. The whole seed is a
|
||||
/// single undo step (the worker wraps the call in one `snapshot_then`)
|
||||
/// and writes exactly one `history.log` line (only the first row
|
||||
/// carries the `source`).
|
||||
///
|
||||
/// Deferred to the next phase (ADR-0048): FK sampling from parent rows
|
||||
/// (D14), the efficient single-transaction multi-row path, identifier
|
||||
/// uniqueness (D10), the `IN`-CHECK value derivation (D17), the
|
||||
/// required-column block guard (D1), the capped auto-show preview
|
||||
/// (D18), and the enum/CHECK advisory (D12/D13).
|
||||
fn do_seed(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
table: &str,
|
||||
count: Option<u64>,
|
||||
rng_seed: Option<u64>,
|
||||
) -> Result<InsertResult, DbError> {
|
||||
use crate::seed;
|
||||
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
let n = count.unwrap_or(DEFAULT_SEED_COUNT);
|
||||
debug!(table = %table, count = n, "seed");
|
||||
|
||||
let schema = read_schema(conn, table)?;
|
||||
|
||||
// FK child columns are filled by the executor in a later phase; for
|
||||
// now they are omitted (left to NULL / default).
|
||||
let fk_children: std::collections::HashSet<&str> = schema
|
||||
.foreign_keys
|
||||
.iter()
|
||||
.flat_map(|fk| fk.child_columns.iter().map(String::as_str))
|
||||
.collect();
|
||||
|
||||
// Columns we generate values for: every user column that is not an
|
||||
// autogen serial/shortid and not an FK child.
|
||||
let gen_columns: Vec<&ReadColumn> = schema
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
!matches!(c.user_type, Some(Type::Serial) | Some(Type::ShortId))
|
||||
&& !fk_children.contains(c.name.as_str())
|
||||
})
|
||||
.collect();
|
||||
let col_names: Vec<String> = gen_columns.iter().map(|c| c.name.clone()).collect();
|
||||
|
||||
let mut rng = seed::make_rng(rng_seed);
|
||||
let mut rows_affected = 0usize;
|
||||
let mut last_data: Option<DataResult> = None;
|
||||
|
||||
for i in 0..n {
|
||||
let values: Vec<Value> = gen_columns
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let ty = c.user_type.unwrap_or(Type::Text);
|
||||
let spec = seed::ColumnSpec {
|
||||
name: c.name.clone(),
|
||||
ty,
|
||||
not_null: c.notnull,
|
||||
primary_key: c.primary_key,
|
||||
unique: c.unique,
|
||||
// FK children are already filtered out above.
|
||||
is_foreign_key: false,
|
||||
// `IN`-CHECK derivation is a later phase.
|
||||
check_in_values: None,
|
||||
};
|
||||
let generator = seed::choose_generator(table, &spec);
|
||||
seed::generate_value(&generator, ty, &mut rng)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Only the first row carries the `source`, so the whole seed
|
||||
// writes exactly one `history.log` line.
|
||||
let row_source = if i == 0 { source } else { None };
|
||||
let result = do_insert(conn, persistence, row_source, table, Some(&col_names), &values)?;
|
||||
rows_affected += result.rows_affected;
|
||||
last_data = Some(result.data);
|
||||
}
|
||||
|
||||
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(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn do_insert(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
|
||||
@@ -402,6 +402,16 @@ pub enum Command {
|
||||
filter: Option<Expr>,
|
||||
limit: Option<u64>,
|
||||
},
|
||||
/// Populate a table with generated fake data (ADR-0048, SD1).
|
||||
/// `count` defaults to 20 when omitted; `rng_seed` (from a future
|
||||
/// `--seed <n>` flag) makes generation reproducible. Phase 1 is
|
||||
/// whole-row generation; the `set` override clause and the
|
||||
/// `<table>.<column>` column-fill form arrive in later phases.
|
||||
Seed {
|
||||
table: String,
|
||||
count: Option<u64>,
|
||||
rng_seed: Option<u64>,
|
||||
},
|
||||
/// Replay a sequence of DSL commands from a file. Each line
|
||||
/// is parsed and dispatched through the same pipeline as
|
||||
/// interactive input. Blank lines and lines whose first
|
||||
@@ -949,6 +959,7 @@ impl Command {
|
||||
} => "show index",
|
||||
Self::ShowList { kind, .. } => kind.command_name(),
|
||||
Self::Insert { .. } => "insert into",
|
||||
Self::Seed { .. } => "seed",
|
||||
Self::Update { .. } => "update",
|
||||
Self::Delete { .. } => "delete from",
|
||||
Self::ShowData { .. } => "show data",
|
||||
@@ -997,6 +1008,7 @@ impl Command {
|
||||
| Self::AddConstraint { table, .. }
|
||||
| Self::DropConstraint { table, .. }
|
||||
| Self::Insert { table, .. }
|
||||
| Self::Seed { table, .. }
|
||||
| Self::Update { table, .. }
|
||||
| Self::Delete { table, .. } => table,
|
||||
// For relationships we focus on the parent (1-side):
|
||||
|
||||
@@ -425,6 +425,24 @@ const LIMIT_CLAUSE_NODES: &[Node] = &[
|
||||
];
|
||||
const LIMIT_CLAUSE: Node = Node::Seq(LIMIT_CLAUSE_NODES);
|
||||
|
||||
// =================================================================
|
||||
// seed — `seed <T> [<count>]` (ADR-0048, SD1)
|
||||
// =================================================================
|
||||
|
||||
/// Optional positional row count. Reuses `LIMIT_VALIDATOR` (a
|
||||
/// non-negative integer). Phase 1 has no `--seed` flag, `set` clause,
|
||||
/// or `<table>.<column>` column-fill form yet.
|
||||
const SEED_COUNT: Node = Node::NumberLit {
|
||||
validator: Some(LIMIT_VALIDATOR),
|
||||
};
|
||||
const SEED_NODES: &[Node] = &[
|
||||
// `writes_table` so a future `set <col>=…` clause's column slots
|
||||
// can resolve against this table.
|
||||
TABLE_NAME_WRITES,
|
||||
Node::Optional(&SEED_COUNT),
|
||||
];
|
||||
const SEED_SHAPE: Node = Node::Seq(SEED_NODES);
|
||||
|
||||
const UPDATE_NODES: &[Node] = &[
|
||||
TABLE_NAME_WRITES,
|
||||
Node::Word(Word::keyword("set")),
|
||||
@@ -708,6 +726,38 @@ fn build_show_limit(path: &MatchedPath) -> Result<Option<u64>, ValidationError>
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a `seed <T> [<count>]` command (ADR-0048). The only
|
||||
/// `NumberLit` in a `seed` path is the optional count.
|
||||
fn build_seed(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::Seed {
|
||||
table: require_ident(path, "table_name")?,
|
||||
count: build_seed_count(path)?,
|
||||
// `--seed <n>` is added in a later phase; reproducibility off
|
||||
// for now.
|
||||
rng_seed: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_seed_count(path: &MatchedPath) -> Result<Option<u64>, ValidationError> {
|
||||
let Some(item) = path
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| matches!(i.kind, MatchedKind::NumberLit))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
item.text
|
||||
.parse::<u64>()
|
||||
.map(Some)
|
||||
.map_err(|_| ValidationError {
|
||||
message_key: "parse.custom.bind_type_mismatch",
|
||||
args: vec![
|
||||
("found", item.text.clone()),
|
||||
("expected", "non-negative integer".to_string()),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let table = require_ident(path, "table_name")?;
|
||||
|
||||
@@ -1452,6 +1502,14 @@ pub static SHOW: CommandNode = CommandNode {
|
||||
"parse.usage.show_index",
|
||||
],};
|
||||
|
||||
pub static SEED: CommandNode = CommandNode {
|
||||
entry: Word::keyword("seed"),
|
||||
shape: SEED_SHAPE,
|
||||
ast_builder: build_seed,
|
||||
help_id: Some("data.seed"),
|
||||
usage_ids: &["parse.usage.seed"],
|
||||
};
|
||||
|
||||
pub static INSERT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("insert"),
|
||||
shape: INSERT_SHAPE,
|
||||
|
||||
@@ -714,6 +714,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&ddl::CREATE, CommandCategory::Simple),
|
||||
(&ddl::CREATE_M2N, CommandCategory::Simple),
|
||||
(&data::SHOW, CommandCategory::Simple),
|
||||
(&data::SEED, CommandCategory::Simple),
|
||||
(&data::INSERT, CommandCategory::Simple),
|
||||
(&data::UPDATE, CommandCategory::Simple),
|
||||
(&data::DELETE, CommandCategory::Simple),
|
||||
|
||||
@@ -207,6 +207,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.ddl.rename", &[]),
|
||||
("help.ddl.change", &[]),
|
||||
("help.data.show", &[]),
|
||||
("help.data.seed", &[]),
|
||||
("help.data.insert", &[]),
|
||||
("help.data.update", &[]),
|
||||
("help.data.delete", &[]),
|
||||
@@ -308,6 +309,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.undo", &[]),
|
||||
("parse.usage.save", &[]),
|
||||
("parse.usage.select", &[]),
|
||||
("parse.usage.seed", &[]),
|
||||
("parse.usage.show_data", &[]),
|
||||
("parse.usage.show_table", &[]),
|
||||
("parse.usage.show_tables", &[]),
|
||||
|
||||
@@ -333,6 +333,10 @@ help:
|
||||
show indexes — list all indexes
|
||||
show relationship <name> — show one relationship's detail
|
||||
show index <name> — show one index's detail
|
||||
seed: |-
|
||||
seed <T> [<count>] — fill a table with generated sample rows
|
||||
(default 20). Existing rows are kept;
|
||||
foreign keys draw from existing parent rows.
|
||||
insert: |-
|
||||
insert into <T> [(cols)] [values] (vals) — add a row
|
||||
update: |-
|
||||
@@ -569,6 +573,7 @@ parse:
|
||||
change_column: |-
|
||||
change column [in] [table] <Table>: <Name> (<Type>)
|
||||
[--force-conversion | --dont-convert]
|
||||
seed: "seed <Table> [count]"
|
||||
show_data: "show data <Table>"
|
||||
show_table: "show table <Table>"
|
||||
show_tables: "show tables"
|
||||
|
||||
@@ -2911,6 +2911,17 @@ 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.
|
||||
Command::Seed {
|
||||
table,
|
||||
count,
|
||||
rng_seed,
|
||||
} => database
|
||||
.seed(table, count, rng_seed, src)
|
||||
.await
|
||||
.map(CommandOutcome::Insert),
|
||||
Command::Update {
|
||||
table,
|
||||
assignments,
|
||||
|
||||
Reference in New Issue
Block a user