1use crate::codegen::{SchemaCode, SchemaCodeOptions, schema_code};
21use crate::config::{CONFIG_FILE, Config, Dialect, IntrospectCasing, Schema};
22use crate::error::CliError;
23use crate::output;
24use drizzle_migrations::schema::Snapshot;
25use drizzle_migrations::upgrade::upgrade_to_latest;
26use drizzle_migrations::version::{is_supported_version, needs_upgrade, snapshot_version};
27use serde_json::Value;
28use std::fmt::Display;
29use std::fs;
30use std::path::{Path, PathBuf};
31
32const DEFAULT_SCHEMA_PATH: &str = "src/schema.rs";
34
35#[derive(clap::Args, Debug, Clone)]
36pub struct ImportOptions {
37 #[arg(value_name = "PATH")]
39 pub path: PathBuf,
40
41 #[arg(long, value_name = "FILE")]
44 pub out: Option<PathBuf>,
45
46 #[arg(long)]
48 pub force: bool,
49
50 #[arg(long)]
52 pub dialect: Option<Dialect>,
53
54 #[arg(long)]
57 pub casing: Option<IntrospectCasing>,
58
59 #[arg(long, value_name = "NAME", default_value = "Schema")]
61 pub schema_name: String,
62
63 #[arg(long)]
66 pub upgrade: bool,
67
68 #[arg(long)]
71 pub init_config: bool,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum Layout {
77 Folders,
80 Journal,
82}
83
84#[derive(Debug)]
86struct Source {
87 snapshot_path: PathBuf,
88 migrations: Option<(PathBuf, Layout)>,
90}
91
92#[derive(Clone, Copy)]
95struct Messages {
96 to_stderr: bool,
97}
98
99impl Messages {
100 fn line(self, text: impl Display) {
101 if self.to_stderr {
102 eprintln!("{text}");
103 } else {
104 println!("{text}");
105 }
106 }
107}
108
109pub fn run(
122 config: Option<&Config>,
123 db_name: Option<&str>,
124 opts: &ImportOptions,
125) -> Result<(), CliError> {
126 let db = config.map(|c| c.database(db_name)).transpose()?;
127 let to_stdout = opts.out.as_deref() == Some(Path::new("-"));
128 let say = Messages {
129 to_stderr: to_stdout,
130 };
131 if to_stdout && opts.upgrade {
132 return Err(CliError::Other(
133 "--upgrade cannot be combined with --out - (it prints to stdout)".into(),
134 ));
135 }
136
137 let source = find_snapshot(&opts.path)?;
138 let contents = fs::read_to_string(&source.snapshot_path)
139 .map_err(|e| CliError::IoError(format!("{}: {e}", source.snapshot_path.display())))?;
140 let json: Value = serde_json::from_str(&contents).map_err(|e| {
141 CliError::Other(format!(
142 "Invalid JSON in {}: {e}",
143 source.snapshot_path.display()
144 ))
145 })?;
146
147 let detected = snapshot_dialect(&json, &source.snapshot_path);
148 let dialect = match (opts.dialect, detected) {
149 (Some(wanted), Ok(found)) if wanted.to_base() != found.to_base() => {
152 return Err(CliError::Other(format!(
153 "{} is a {} snapshot; --dialect {} does not match it",
154 source.snapshot_path.display(),
155 found.as_str(),
156 wanted.as_str()
157 )));
158 }
159 (Some(wanted), _) => wanted,
160 (None, detected) => detected?,
161 };
162 let snapshot = load_snapshot(json, dialect, &source.snapshot_path)?;
163
164 let casing = opts.casing.or_else(|| {
165 db.and_then(|db| db.introspect.as_ref())
166 .map(|introspect| introspect.casing)
167 });
168 let module_doc = format!(
169 "Imported from the drizzle-kit snapshot {}",
170 forward_slashes(&source.snapshot_path)
171 );
172 let generated = schema_code(
173 &snapshot,
174 &SchemaCodeOptions {
175 casing,
176 module_doc: &module_doc,
177 schema_name: &opts.schema_name,
178 },
179 )?;
180
181 let out_path = if to_stdout {
182 None
183 } else {
184 Some(resolve_out_path(opts.out.as_deref(), db)?)
185 };
186
187 say.line(output::heading("Importing drizzle-kit snapshot..."));
188 say.line("");
189 say.line(format!(
190 " {}: {}",
191 output::label("Snapshot"),
192 source.snapshot_path.display()
193 ));
194 say.line(format!(
195 " {}: {}",
196 output::label("Dialect"),
197 dialect.as_str()
198 ));
199 say.line(format!(
200 " {}: {}",
201 output::label("Output"),
202 out_path
203 .as_deref()
204 .map_or_else(|| "stdout".to_string(), |p| p.display().to_string())
205 ));
206
207 match &out_path {
208 None => print!("{}", generated.code),
209 Some(path) => write_schema(path, &generated.code, opts.force)?,
210 }
211
212 print_summary(say, &generated);
213
214 if opts.upgrade {
215 match &source.migrations {
216 Some((dir, Layout::Journal)) => {
217 say.line("");
218 let converted =
219 crate::commands::upgrade::convert_legacy_layout(dir, dialect.to_base())?;
220 say.line(output::success(&format!(
221 "Converted {converted} migration(s) in {} to the folder layout",
222 dir.display()
223 )));
224 }
225 _ => say.line(output::warning(
226 "--upgrade: the snapshot is not in a journal-layout folder; nothing to convert",
227 )),
228 }
229 }
230
231 let migrations = source.migrations.as_ref().map(|(dir, layout)| {
232 let layout = if opts.upgrade {
233 Layout::Folders
234 } else {
235 *layout
236 };
237 (dir.as_path(), layout)
238 });
239
240 let wrote_config = if opts.init_config {
241 init_config(say, config, dialect, out_path.as_deref(), migrations)?
242 } else {
243 false
244 };
245
246 print_next_steps(
247 say,
248 &NextSteps {
249 config,
250 db_out: db.map(crate::config::DatabaseConfig::migrations_dir),
251 wrote_config,
252 dialect,
253 schema_path: out_path.as_deref(),
254 migrations,
255 },
256 );
257 print_manual_port_notes(say);
258
259 Ok(())
260}
261
262fn find_snapshot(path: &Path) -> Result<Source, CliError> {
267 if path.is_file() {
268 return Ok(Source {
269 snapshot_path: path.to_path_buf(),
270 migrations: migrations_around_file(path),
271 });
272 }
273 if !path.is_dir() {
274 return Err(CliError::Other(format!(
275 "{} does not exist. Pass a drizzle-kit migrations folder (the `out` of drizzle.config.ts) or a snapshot.json file.",
276 path.display()
277 )));
278 }
279
280 let journal_path = path.join("meta").join("_journal.json");
281 if journal_path.is_file() {
282 return Ok(Source {
283 snapshot_path: last_journal_snapshot(path, &journal_path)?,
284 migrations: Some((path.to_path_buf(), Layout::Journal)),
285 });
286 }
287
288 let mut folders = Vec::new();
289 for entry in fs::read_dir(path).map_err(|e| CliError::IoError(e.to_string()))? {
290 let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
291 let candidate = entry.path().join("snapshot.json");
292 if candidate.is_file() {
293 folders.push(entry.file_name());
294 }
295 }
296 folders.sort();
299 let Some(newest) = folders.last() else {
300 return Err(CliError::Other(format!(
301 "No drizzle-kit snapshots found in {}: expected <tag>/snapshot.json folders or meta/_journal.json. Pass the `out` folder of drizzle.config.ts, or a snapshot.json file.",
302 path.display()
303 )));
304 };
305 Ok(Source {
306 snapshot_path: path.join(newest).join("snapshot.json"),
307 migrations: Some((path.to_path_buf(), Layout::Folders)),
308 })
309}
310
311fn last_journal_snapshot(dir: &Path, journal_path: &Path) -> Result<PathBuf, CliError> {
313 let text = fs::read_to_string(journal_path).map_err(|e| CliError::IoError(e.to_string()))?;
314 let journal: Value = serde_json::from_str(&text)
315 .map_err(|e| CliError::Other(format!("Invalid JSON in {}: {e}", journal_path.display())))?;
316 let last = journal
317 .get("entries")
318 .and_then(Value::as_array)
319 .and_then(|entries| entries.last())
320 .ok_or_else(|| {
321 CliError::Other(format!(
322 "{} lists no migrations, so there is no snapshot to import",
323 journal_path.display()
324 ))
325 })?;
326 let idx = last.get("idx").and_then(Value::as_u64).ok_or_else(|| {
327 CliError::Other(format!(
328 "The last entry of {} has no 'idx'",
329 journal_path.display()
330 ))
331 })?;
332 let snapshot_path = dir.join("meta").join(format!("{idx:04}_snapshot.json"));
333 if !snapshot_path.is_file() {
334 return Err(CliError::Other(format!(
335 "{} is missing (named by the last entry of {})",
336 snapshot_path.display(),
337 journal_path.display()
338 )));
339 }
340 Ok(snapshot_path)
341}
342
343fn migrations_around_file(file: &Path) -> Option<(PathBuf, Layout)> {
346 let parent = file.parent()?;
347 let grandparent = parent.parent()?;
348 if parent.file_name().is_some_and(|name| name == "meta")
349 && parent.join("_journal.json").is_file()
350 {
351 return Some((grandparent.to_path_buf(), Layout::Journal));
352 }
353 if file.file_name().is_some_and(|name| name == "snapshot.json")
354 && parent.join("migration.sql").is_file()
355 {
356 return Some((grandparent.to_path_buf(), Layout::Folders));
357 }
358 None
359}
360
361fn snapshot_dialect(json: &Value, path: &Path) -> Result<Dialect, CliError> {
367 let Some(name) = json.get("dialect").and_then(Value::as_str) else {
368 return Err(CliError::Other(format!(
369 "{} has no \"dialect\" field; pass --dialect",
370 path.display()
371 )));
372 };
373 match name {
374 "sqlite" => Ok(Dialect::Sqlite),
375 "turso" => Ok(Dialect::Turso),
376 "postgresql" | "postgres" | "pg" => Ok(Dialect::Postgresql),
377 "mysql" => Ok(Dialect::Mysql),
378 other => Err(CliError::Other(format!(
379 "{} is a \"{other}\" snapshot; drizzle-rs supports sqlite, turso, postgresql and mysql",
380 path.display()
381 ))),
382 }
383}
384
385fn load_snapshot(json: Value, dialect: Dialect, path: &Path) -> Result<Snapshot, CliError> {
388 let base = dialect.to_base();
389 let version = match json.get("version") {
390 Some(Value::String(version)) => version.clone(),
391 Some(Value::Number(version)) => version.to_string(),
392 _ => {
393 return Err(CliError::Other(format!(
394 "{} has no snapshot \"version\"; is it a drizzle-kit snapshot?",
395 path.display()
396 )));
397 }
398 };
399 if !is_supported_version(base, &version) {
400 use drizzle_migrations::version::{
401 MYSQL_MIN_SUPPORTED_VERSION, POSTGRES_MIN_SUPPORTED_VERSION,
402 SQLITE_MIN_SUPPORTED_VERSION,
403 };
404 let min = match base {
405 drizzle_types::Dialect::SQLite => SQLITE_MIN_SUPPORTED_VERSION,
406 drizzle_types::Dialect::PostgreSQL => POSTGRES_MIN_SUPPORTED_VERSION,
407 drizzle_types::Dialect::MySQL => MYSQL_MIN_SUPPORTED_VERSION,
408 };
409 let latest = snapshot_version(base);
410 let hint = if needs_upgrade(base, &version) {
411 "Run `npx drizzle-kit up` in the TypeScript project to upgrade its snapshots, then import again."
412 } else {
413 "It is newer than this drizzle-cli understands; update drizzle-cli."
414 };
415 return Err(CliError::Other(format!(
416 "{} is a {} snapshot at version {version}; drizzle import reads versions {min} to {latest}. {hint}",
417 path.display(),
418 dialect.as_str(),
419 )));
420 }
421
422 let upgraded = upgrade_to_latest(json, base);
423 let invalid = |e: &dyn Display| {
424 CliError::Other(format!(
425 "{} did not convert to a {} snapshot: {e}",
426 path.display(),
427 dialect.as_str()
428 ))
429 };
430 let snapshot = match base {
431 drizzle_types::Dialect::SQLite => {
432 Snapshot::Sqlite(serde_json::from_value(upgraded).map_err(|e| invalid(&e))?)
433 }
434 drizzle_types::Dialect::PostgreSQL => {
435 Snapshot::Postgres(serde_json::from_value(upgraded).map_err(|e| invalid(&e))?)
436 }
437 drizzle_types::Dialect::MySQL => {
438 let snapshot: drizzle_migrations::mysql::MySQLSnapshot =
439 serde_json::from_value(upgraded).map_err(|e| invalid(&e))?;
440 drizzle_migrations::mysql::MySQLDDL::try_from_entities(snapshot.ddl.clone())
441 .map_err(|e| invalid(&e))?;
442 Snapshot::MySQL(snapshot)
443 }
444 };
445 Ok(snapshot)
446}
447
448fn resolve_out_path(
454 out: Option<&Path>,
455 db: Option<&crate::config::DatabaseConfig>,
456) -> Result<PathBuf, CliError> {
457 if let Some(out) = out {
458 return Ok(out.to_path_buf());
459 }
460 let Some(db) = db else {
461 return Ok(PathBuf::from(DEFAULT_SCHEMA_PATH));
462 };
463 match &db.schema {
464 Schema::One(path) if !path.contains(['*', '?', '[', '{']) => Ok(PathBuf::from(path)),
465 _ => Err(CliError::Other(format!(
466 "The config's schema ({}) is not a single file; pass --out <FILE>",
467 db.schema_display()
468 ))),
469 }
470}
471
472fn write_schema(path: &Path, code: &str, force: bool) -> Result<(), CliError> {
473 if path.exists() && !force {
474 return Err(CliError::Other(format!(
475 "{} already exists. Pass --force to overwrite it, or --out <FILE> to write elsewhere.",
476 path.display()
477 )));
478 }
479 if let Some(parent) = path.parent()
480 && !parent.as_os_str().is_empty()
481 {
482 fs::create_dir_all(parent).map_err(|e| {
483 CliError::Other(format!(
484 "Failed to create output directory '{}': {e}",
485 parent.display()
486 ))
487 })?;
488 }
489 fs::write(path, code).map_err(|e| {
490 CliError::Other(format!(
491 "Failed to write schema file '{}': {e}",
492 path.display()
493 ))
494 })
495}
496
497fn print_summary(say: Messages, generated: &SchemaCode) {
498 say.line("");
499 for line in crate::commands::introspect::summary_lines(
500 generated.table_count,
501 generated.index_count,
502 generated.view_count,
503 &generated.warnings,
504 ) {
505 say.line(line);
506 }
507}
508
509fn init_config(
516 say: Messages,
517 config: Option<&Config>,
518 dialect: Dialect,
519 schema_path: Option<&Path>,
520 migrations: Option<(&Path, Layout)>,
521) -> Result<bool, CliError> {
522 let config_path = Path::new(CONFIG_FILE);
523 if config.is_some() || config_path.exists() {
524 say.line(output::warning(&format!(
525 "--init-config: a config already exists, so {CONFIG_FILE} was not written"
526 )));
527 return Ok(false);
528 }
529 let schema = schema_path.map_or_else(|| DEFAULT_SCHEMA_PATH.to_string(), forward_slashes);
530 let out = migrations.map_or_else(
531 || crate::commands::init::DEFAULT_OUT.to_string(),
532 |(dir, _)| forward_slashes(dir),
533 );
534 let contents = crate::commands::init::config_contents(dialect.as_str(), None, &schema, &out)?;
535 fs::write(config_path, contents).map_err(|e| CliError::IoError(e.to_string()))?;
536 say.line("");
537 say.line(output::success(&format!(
538 "Created {CONFIG_FILE} (schema = \"{schema}\", out = \"{out}\")"
539 )));
540 Ok(true)
541}
542
543fn forward_slashes(path: &Path) -> String {
546 path.to_string_lossy().replace('\\', "/")
547}
548
549struct NextSteps<'a> {
550 config: Option<&'a Config>,
551 db_out: Option<&'a Path>,
552 wrote_config: bool,
553 dialect: Dialect,
554 schema_path: Option<&'a Path>,
555 migrations: Option<(&'a Path, Layout)>,
556}
557
558fn print_next_steps(say: Messages, steps: &NextSteps<'_>) {
559 say.line("");
560 say.line(output::heading("Next steps:"));
561 let mut n = 0;
562 let mut step = |text: String| {
563 n += 1;
564 say.line(format!(" {n}. {text}"));
565 };
566
567 let out_display = steps
568 .migrations
569 .map_or_else(|| "./drizzle".to_string(), |(dir, _)| forward_slashes(dir));
570
571 if steps.wrote_config {
572 step(format!(
573 "Fill in [dbCredentials] in {CONFIG_FILE} (it points `out` at {out_display})."
574 ));
575 } else if steps.config.is_none() {
576 let schema = steps
577 .schema_path
578 .map_or_else(|| DEFAULT_SCHEMA_PATH.to_string(), forward_slashes);
579 step(format!(
580 "Create {CONFIG_FILE} with the existing migrations folder as `out` (or rerun with --init-config):"
581 ));
582 say.line(output::muted(&format!(
583 " dialect = \"{}\"",
584 steps.dialect.as_str()
585 )));
586 say.line(output::muted(&format!(" schema = \"{schema}\"")));
587 say.line(output::muted(&format!(" out = \"{out_display}\"")));
588 } else if let Some((dir, _)) = steps.migrations
589 && steps.db_out.is_none_or(|out| !same_dir(out, dir))
590 {
591 step(format!(
592 "Set `out = \"{out_display}\"` in {CONFIG_FILE} so drizzle-rs uses the existing migrations."
593 ));
594 }
595
596 if let Some((_, Layout::Journal)) = steps.migrations {
597 step(format!(
598 "Convert the migrations folder to the folder layout drizzle-rs reads (moves the files in place; or rerun with --upgrade):\n {}",
599 output::muted(&format!("drizzle up --out {out_display}"))
600 ));
601 }
602
603 step(format!(
604 "{} sees the migrations drizzle-orm already applied in its tracking table (`__drizzle_migrations`; schema `drizzle` on PostgreSQL) and runs only new ones. If drizzle.config.ts set `migrations.table` or `migrations.schema`, set the same under [migrations].",
605 output::heading("drizzle migrate")
606 ));
607 step(format!(
608 "Run {}: for an unchanged schema it reports no changes.",
609 output::heading("drizzle generate")
610 ));
611}
612
613fn same_dir(a: &Path, b: &Path) -> bool {
614 match (fs::canonicalize(a), fs::canonicalize(b)) {
615 (Ok(a), Ok(b)) => a == b,
616 _ => a == b,
617 }
618}
619
620fn print_manual_port_notes(say: Messages) {
621 say.line("");
622 say.line(output::heading(
623 "Snapshots do not record these; port them by hand:",
624 ));
625 for note in [
626 "relations(): declare foreign keys with #[column(references = Table::column)]; relation accessors come from those",
627 "$type<T>() and column modes ({ mode: 'json' | 'timestamp' | 'bigint' }): fields get the column's storage type; switch to your Rust type (e.g. #[column(json)] with a serde type, or a #[derive(...Enum)])",
628 "$default / $defaultFn / $onUpdate / $onUpdateFn: values computed in JS; use #[column(default_fn = path)] or set them in code",
629 "customType(): the column keeps its SQL type; map it to a Rust type yourself",
630 ] {
631 say.line(format!(" {} {note}", output::warning("-")));
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use serde_json::json;
639
640 #[test]
641 fn dialect_spellings() {
642 let path = Path::new("snapshot.json");
643 for (name, dialect) in [
644 ("sqlite", Dialect::Sqlite),
645 ("turso", Dialect::Turso),
646 ("postgresql", Dialect::Postgresql),
647 ("postgres", Dialect::Postgresql),
648 ("mysql", Dialect::Mysql),
649 ] {
650 assert_eq!(
651 snapshot_dialect(&json!({ "dialect": name }), path).unwrap(),
652 dialect
653 );
654 }
655 assert!(snapshot_dialect(&json!({ "dialect": "singlestore" }), path).is_err());
656 assert!(snapshot_dialect(&json!({}), path).is_err());
657 }
658
659 #[test]
660 fn old_and_future_versions_are_refused() {
661 let path = Path::new("snapshot.json");
662 let old = load_snapshot(
663 json!({ "version": "4", "dialect": "sqlite" }),
664 Dialect::Sqlite,
665 path,
666 )
667 .unwrap_err()
668 .to_string();
669 assert!(old.contains("version 4"), "{old}");
670 assert!(old.contains("drizzle-kit up"), "{old}");
671
672 let future = load_snapshot(
673 json!({ "version": "99", "dialect": "postgresql" }),
674 Dialect::Postgresql,
675 path,
676 )
677 .unwrap_err()
678 .to_string();
679 assert!(future.contains("update drizzle-cli"), "{future}");
680 }
681}