1use crate::commands::overrides::{self, ConnectionOverrides, FilterArgs};
7use crate::config::{Config, Dialect, Driver};
8use crate::error::CliError;
9use crate::output;
10use std::path::PathBuf;
11
12#[derive(clap::Args, Debug, Clone)]
13pub struct SeedOptions {
14 #[arg(long, default_value_t = 0)]
16 pub seed: u64,
17
18 #[arg(long, value_name = "N|TABLE=N")]
22 pub count: Vec<String>,
23
24 #[arg(long, value_name = "PARENT:CHILD=N")]
27 pub relation: Vec<String>,
28
29 #[arg(long, value_name = "TABLE")]
31 pub skip: Vec<String>,
32
33 #[arg(long)]
35 pub reset: bool,
36
37 #[arg(long, value_name = "FILE")]
39 pub out: Option<PathBuf>,
40
41 #[arg(long)]
43 pub force: bool,
44
45 #[arg(long)]
47 pub dialect: Option<Dialect>,
48
49 #[command(flatten)]
50 pub filters: FilterArgs,
51
52 #[command(flatten)]
53 pub connection: ConnectionOverrides,
54}
55
56#[derive(Debug, Default, PartialEq, Eq)]
58pub struct SeedPlan {
59 pub seed: u64,
61 pub default_count: Option<usize>,
63 pub counts: Vec<(String, usize)>,
65 pub relations: Vec<(String, String, usize)>,
67 pub skips: Vec<String>,
69 pub reset: bool,
71}
72
73impl SeedPlan {
74 pub fn from_options(opts: &SeedOptions) -> Result<Self, CliError> {
81 let number = |text: &str, flag: &str, value: &str| {
82 text.trim().parse::<usize>().map_err(|_| {
83 CliError::Other(format!(
84 "--{flag} {value}: `{}` is not a row count",
85 text.trim()
86 ))
87 })
88 };
89 let mut plan = Self {
90 seed: opts.seed,
91 reset: opts.reset,
92 skips: opts.skip.clone(),
93 ..Self::default()
94 };
95 for value in &opts.count {
96 match value.rsplit_once('=') {
97 Some((table, count)) if !table.trim().is_empty() => {
98 plan.counts
99 .push((table.trim().to_owned(), number(count, "count", value)?));
100 }
101 Some(_) => {
102 return Err(CliError::Other(format!(
103 "--count {value}: expected N or TABLE=N"
104 )));
105 }
106 None if plan.default_count.is_some() => {
107 return Err(CliError::Other(
108 "--count without a table was given twice".to_owned(),
109 ));
110 }
111 None => plan.default_count = Some(number(value, "count", value)?),
112 }
113 }
114 for value in &opts.relation {
115 let parsed = value.rsplit_once('=').and_then(|(tables, count)| {
116 let (parent, child) = tables.split_once(':')?;
117 (!parent.trim().is_empty() && !child.trim().is_empty())
118 .then(|| (parent.trim().to_owned(), child.trim().to_owned(), count))
119 });
120 let Some((parent, child, count)) = parsed else {
121 return Err(CliError::Other(format!(
122 "--relation {value}: expected PARENT:CHILD=N"
123 )));
124 };
125 plan.relations
126 .push((parent, child, number(count, "relation", value)?));
127 }
128 Ok(plan)
129 }
130}
131
132#[derive(Debug, Default)]
134pub struct SeedScript {
135 pub reset: Vec<String>,
137 pub inserts: Vec<String>,
139 pub rows: Vec<(String, usize)>,
141}
142
143impl SeedScript {
144 #[allow(dead_code)] fn add_rows(&mut self, table: &str, rows: usize) {
146 match self.rows.last_mut() {
147 Some((last, count)) if last == table => *count += rows,
148 _ if rows > 0 => self.rows.push((table.to_owned(), rows)),
149 _ => {}
150 }
151 }
152
153 #[must_use]
155 pub fn to_sql(&self) -> String {
156 let mut sql = String::new();
157 for statement in self.reset.iter().chain(&self.inserts) {
158 sql.push_str(statement);
159 sql.push_str(";\n");
160 }
161 sql
162 }
163}
164
165fn seed_error(error: drizzle_seed::SeedError) -> CliError {
166 CliError::Other(format!("Seeding failed: {error}"))
167}
168
169#[allow(unused_macros)] macro_rules! build_script {
173 ($constructor:ident, $schema:expr, $plan:expr, $max_params:expr) => {{
174 let plan: &SeedPlan = $plan;
175 let mut config = drizzle_seed::SeedConfig::$constructor($schema).seed(plan.seed);
176 if let Some(count) = plan.default_count {
177 config = config.default_count(count);
178 }
179 if let Some(limit) = $max_params {
180 config = config.max_params(limit);
181 }
182 for (table, count) in &plan.counts {
183 config = config.count_by_name(table, *count);
184 }
185 for (parent, child, count) in &plan.relations {
186 config = config.relation_by_name(parent, child, *count);
187 }
188 for table in &plan.skips {
189 config = config.skip_by_name(table);
190 }
191 let mut script = SeedScript::default();
192 if plan.reset {
193 for statement in config.reset_plan().map_err(seed_error)? {
194 script
195 .reset
196 .push(statement.inline_sql().map_err(seed_error)?);
197 }
198 }
199 for statement in config.try_generate().map_err(seed_error)? {
200 script.add_rows(statement.table(), statement.rows());
201 script
202 .inserts
203 .push(statement.inline_sql().map_err(seed_error)?);
204 }
205 Ok(script)
206 }};
207}
208
209#[allow(unused_variables)] pub fn build_script(
221 dialect: drizzle_types::Dialect,
222 schema: &drizzle_seed::schema::Schema,
223 plan: &SeedPlan,
224 max_params: Option<usize>,
225) -> Result<SeedScript, CliError> {
226 match dialect {
227 #[cfg(any(
228 feature = "rusqlite",
229 feature = "libsql",
230 feature = "turso",
231 feature = "d1-http"
232 ))]
233 drizzle_types::Dialect::SQLite => build_script!(sqlite, schema, plan, max_params),
234 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
235 drizzle_types::Dialect::PostgreSQL => build_script!(postgres, schema, plan, max_params),
236 #[cfg(any(feature = "mysql-sync", feature = "mysql-async"))]
237 drizzle_types::Dialect::MySQL => build_script!(mysql, schema, plan, max_params),
238 #[allow(unreachable_patterns)]
239 other => Err(CliError::Other(format!(
240 "this drizzle build has no {other:?} driver; build it with a driver feature to seed"
241 ))),
242 }
243}
244
245pub fn run(config: &Config, db_name: Option<&str>, opts: &SeedOptions) -> Result<(), CliError> {
257 let db = config.database(db_name)?;
258 let plan = SeedPlan::from_options(opts)?;
259 let effective_dialect = overrides::resolve_dialect(db, opts.dialect);
260 let to_stdout = opts.out.as_deref() == Some(std::path::Path::new("-"));
261 let say = |line: String| {
263 if to_stdout {
264 eprintln!("{line}");
265 } else {
266 println!("{line}");
267 }
268 };
269
270 if !to_stdout {
271 crate::commands::harness::print_db_header(config, db_name);
272 }
273 say(output::heading("Seeding database..."));
274 say(format!(
275 " {}: {}",
276 output::label("Dialect"),
277 effective_dialect.as_str()
278 ));
279
280 let connection = overrides::resolve_connection(db, effective_dialect, &opts.connection)?;
281 let Some(connection) = connection else {
282 return Err(CliError::MissingCredentials("seed"));
283 };
284 say(format!(
285 " {}: {}",
286 output::label("Driver"),
287 connection.driver
288 ));
289
290 let filters = crate::db::SnapshotFilters {
291 tables: overrides::resolve_filter_list(
292 opts.filters.tables_filter.as_deref(),
293 db.tables_filter.as_ref(),
294 ),
295 schemas: overrides::resolve_schema_filters(
296 effective_dialect,
297 opts.filters.schema_filters.as_deref(),
298 db.schema_filter.as_ref(),
299 ),
300 extensions: overrides::resolve_extensions_filter(
301 effective_dialect,
302 opts.filters.extensions_filters.as_deref(),
303 db.extensions_filters.as_deref(),
304 ),
305 roles: None,
306 };
307 let snapshot = crate::db::introspect_for_seed(&connection, &filters, db.migrations_table())?;
308 let schema = drizzle_seed::schema::Schema::from_snapshot(&snapshot).map_err(seed_error)?;
309 if schema.tables().is_empty() {
310 say(output::warning("No tables found to seed."));
311 return Ok(());
312 }
313
314 let max_params = (connection.driver == Driver::D1Http).then_some(250);
315 let script = build_script(effective_dialect.to_base(), &schema, &plan, max_params)?;
316
317 if let Some(out) = &opts.out {
318 let sql = script.to_sql();
319 if to_stdout {
320 print!("{sql}");
321 } else {
322 std::fs::write(out, sql).map_err(|error| {
323 CliError::IoError(format!("Failed to write {}: {error}", out.display()))
324 })?;
325 say(format!(
326 "{} Wrote {} statement(s) to {}",
327 output::success("Done!"),
328 script.reset.len() + script.inserts.len(),
329 out.display()
330 ));
331 }
332 return Ok(());
333 }
334
335 if plan.reset && !opts.force && !confirm_reset(&script)? {
336 return Err(CliError::Aborted(
337 "Seed aborted: --reset was not confirmed, so nothing was changed".into(),
338 ));
339 }
340
341 let statements: Vec<String> = script
342 .reset
343 .iter()
344 .chain(&script.inserts)
345 .cloned()
346 .collect();
347 crate::db::execute_seed(&connection, &statements)?;
348
349 println!();
350 for (table, rows) in &script.rows {
351 println!(" {} {table}: {rows} row(s)", output::success("+"));
352 }
353 println!();
354 println!("{}", output::success("Seed complete!"));
355 Ok(())
356}
357
358fn confirm_reset(script: &SeedScript) -> Result<bool, CliError> {
360 use std::io::{self, IsTerminal, Write};
361
362 if !io::stdin().is_terminal() {
363 return Err(CliError::Other(
364 "Refusing to delete rows for --reset in non-interactive mode. Use --force.".into(),
365 ));
366 }
367 println!(
368 "{}",
369 output::warning(&format!(
370 "--reset deletes every row of the {} seeded table(s) first.",
371 script.rows.len()
372 ))
373 );
374 print!("Continue? [y/N]: ");
375 io::stdout()
376 .flush()
377 .map_err(|error| CliError::IoError(error.to_string()))?;
378 let mut answer = String::new();
379 io::stdin()
380 .read_line(&mut answer)
381 .map_err(|error| CliError::IoError(error.to_string()))?;
382 Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "YES" | "Yes"))
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use clap::Parser;
389
390 #[derive(Parser)]
391 struct Cli {
392 #[command(flatten)]
393 seed: SeedOptions,
394 }
395
396 fn plan(args: &[&str]) -> Result<SeedPlan, CliError> {
397 let mut argv = vec!["drizzle"];
398 argv.extend_from_slice(args);
399 SeedPlan::from_options(&Cli::parse_from(argv).seed)
400 }
401
402 #[test]
403 fn parses_counts_relations_and_skips() {
404 let plan = plan(&[
405 "--seed",
406 "7",
407 "--count",
408 "20",
409 "--count",
410 "users=100",
411 "--count",
412 "auth.sessions = 5",
413 "--relation",
414 "users:posts=3",
415 "--skip",
416 "audit_log",
417 "--reset",
418 ])
419 .unwrap();
420 assert_eq!(
421 plan,
422 SeedPlan {
423 seed: 7,
424 default_count: Some(20),
425 counts: vec![("users".into(), 100), ("auth.sessions".into(), 5)],
426 relations: vec![("users".into(), "posts".into(), 3)],
427 skips: vec!["audit_log".into()],
428 reset: true,
429 }
430 );
431 }
432
433 #[test]
434 fn rejects_malformed_values() {
435 for args in [
436 &["--count", "lots"][..],
437 &["--count", "users=lots"],
438 &["--count", "=5"],
439 &["--count", "1", "--count", "2"],
440 &["--relation", "users=3"],
441 &["--relation", "users:=3"],
442 &["--relation", "users:posts=x"],
443 ] {
444 assert!(plan(args).is_err(), "{args:?}");
445 }
446 }
447}