1use std::path::{Path, PathBuf};
4
5use crate::cli::MigrateArgs;
6use crate::commands::seed::{SeedRunner, find_seed_file, get_database_url};
7use crate::config::{CONFIG_FILE_NAME, Config, MIGRATIONS_DIR, SCHEMA_FILE_PATH};
8use crate::error::{CliError, CliResult};
9use crate::output::{self, success, warn};
10
11pub async fn run(args: MigrateArgs) -> CliResult<()> {
13 match args.command {
14 crate::cli::MigrateSubcommand::Dev(dev_args) => run_dev(dev_args).await,
15 crate::cli::MigrateSubcommand::Deploy => run_deploy().await,
16 crate::cli::MigrateSubcommand::Reset(reset_args) => run_reset(reset_args).await,
17 crate::cli::MigrateSubcommand::Status => run_status().await,
18 crate::cli::MigrateSubcommand::Resolve(resolve_args) => run_resolve(resolve_args).await,
19 crate::cli::MigrateSubcommand::Diff(diff_args) => run_diff(diff_args).await,
20 crate::cli::MigrateSubcommand::Rollback(rollback_args) => run_rollback(rollback_args).await,
21 crate::cli::MigrateSubcommand::History(history_args) => run_history(history_args).await,
22 }
23}
24
25async fn run_dev(args: crate::cli::MigrateDevArgs) -> CliResult<()> {
27 output::header("Migrate Dev");
28
29 let cwd = std::env::current_dir()?;
30 let config = load_config(&cwd)?;
31
32 let migrations_dir = cwd.join(MIGRATIONS_DIR);
33
34 let display_path = args
35 .schema
36 .as_deref()
37 .map(|p| p.display().to_string())
38 .unwrap_or_else(|| SCHEMA_FILE_PATH.to_string());
39 output::kv("Schema", &display_path);
40 output::kv("Migrations", &migrations_dir.display().to_string());
41 output::newline();
42
43 let total_steps = if args.skip_seed { 5 } else { 6 };
45
46 output::step(1, total_steps, "Parsing schema...");
48 let loaded = crate::schema_loader::load_schema(args.schema.as_deref())?;
49 let schema = loaded.schema;
50
51 output::step(2, total_steps, "Checking migration status...");
53 let pending = check_pending_migrations(&migrations_dir)?;
54
55 if !pending.is_empty() {
56 output::list(&format!("{} pending migrations found:", pending.len()));
57 for migration in &pending {
58 output::list_item(&migration.display().to_string());
59 }
60 output::newline();
61 }
62
63 output::step(3, total_steps, "Comparing schema to database...");
65 let migration_name = args
66 .name
67 .unwrap_or_else(|| format!("migration_{}", chrono::Utc::now().format("%Y%m%d%H%M%S")));
68
69 let source = resolve_source_schema(&config).await?;
72 let migration_sql = generate_migration_sql(&schema, source, &config, args.allow_destructive)?;
73
74 output::step(4, total_steps, "Generating migration...");
76 if migration_sql.trim().is_empty() {
77 output::newline();
78 success("No changes: the database already matches the schema. No migration created.");
79 return Ok(());
80 }
81 let migration_path = create_migration(&migrations_dir, &migration_name, &migration_sql)?;
82
83 if !args.create_only {
85 output::step(5, total_steps, "Applying migration...");
86 apply_migration(&migration_path, &config).await?;
87 } else {
88 output::step(5, total_steps, "Skipping apply (--create-only)...");
89 }
90
91 if !args.skip_seed && !args.create_only {
93 output::step(6, total_steps, "Running seed...");
94
95 if let Some(seed_path) = find_seed_file(&cwd, &config) {
96 let database_url = get_database_url(&config)?;
97 let runner = SeedRunner::new(
98 seed_path,
99 database_url,
100 config.database.provider.clone(),
101 cwd.clone(),
102 )?;
103
104 match runner.run().await {
105 Ok(result) => {
106 output::list_item(&format!("Seeded {} records", result.records_affected));
107 }
108 Err(e) => {
109 output::warn(&format!("Seed failed: {}. Continuing...", e));
110 }
111 }
112 } else {
113 output::list_item("No seed file found, skipping");
114 }
115 }
116
117 output::newline();
118 success(&format!("Migration '{}' created", migration_name));
119
120 output::newline();
121 output::section("Next steps");
122 output::list_item("Review the generated migration SQL");
123 output::list_item("Run `prax generate` to update your client");
124
125 Ok(())
126}
127
128async fn run_deploy() -> CliResult<()> {
130 output::header("Migrate Deploy");
131
132 let cwd = std::env::current_dir()?;
133 let config = load_config(&cwd)?;
134 let migrations_dir = cwd.join(MIGRATIONS_DIR);
135
136 output::kv("Migrations", &migrations_dir.display().to_string());
137 output::newline();
138
139 output::step(1, 3, "Checking for pending migrations...");
141 let pending = check_pending_migrations(&migrations_dir)?;
142
143 if pending.is_empty() {
144 output::newline();
145 success("No pending migrations to apply.");
146 return Ok(());
147 }
148
149 output::list(&format!("{} pending migrations:", pending.len()));
150 for migration in &pending {
151 output::list_item(&migration.file_name().unwrap().to_string_lossy());
152 }
153 output::newline();
154
155 output::step(2, 3, "Applying migrations...");
157 for migration in &pending {
158 output::list_item(&format!(
159 "Applying {}",
160 migration.file_name().unwrap().to_string_lossy()
161 ));
162 apply_migration(migration, &config).await?;
163 }
164
165 output::step(3, 3, "Verifying migrations...");
167
168 output::newline();
169 success(&format!(
170 "Applied {} migrations successfully!",
171 pending.len()
172 ));
173
174 Ok(())
175}
176
177async fn run_reset(args: crate::cli::MigrateResetArgs) -> CliResult<()> {
179 output::header("Migrate Reset");
180
181 let cwd = std::env::current_dir()?;
182 let _config = load_config(&cwd)?;
183
184 if !args.force {
185 warn("This will delete all data in the database!");
186 output::newline();
187 if !output::confirm("Are you sure you want to reset the database?") {
188 output::newline();
189 output::info("Reset cancelled.");
190 return Ok(());
191 }
192 }
193
194 output::newline();
195
196 Err(CliError::Migration(
199 "migrate reset is not yet implemented: dropping and recreating the database \
200 requires a database executor that is not yet wired into the CLI. No changes \
201 were made to the database."
202 .to_string(),
203 ))
204}
205
206async fn run_status() -> CliResult<()> {
208 output::header("Migration Status");
209
210 let cwd = std::env::current_dir()?;
211 let _config = load_config(&cwd)?;
212 let migrations_dir = cwd.join(MIGRATIONS_DIR);
213
214 let mut migrations = Vec::new();
216 if migrations_dir.exists() {
217 for entry in std::fs::read_dir(&migrations_dir)? {
218 let entry = entry?;
219 let path = entry.path();
220 if path.is_dir() {
221 migrations.push(path);
222 }
223 }
224 }
225 migrations.sort();
226
227 if migrations.is_empty() {
228 output::info("No migrations found.");
229 output::newline();
230 output::section("Getting started");
231 output::list_item("Run `prax migrate dev` to create your first migration");
232 return Ok(());
233 }
234
235 output::section("Migrations");
236
237 for (i, migration) in migrations.iter().enumerate() {
238 let name = migration.file_name().unwrap().to_string_lossy();
239 let applied = is_migration_applied(migration)?;
240
241 let status = if applied {
242 output::style_success("✓ Applied")
243 } else {
244 output::style_pending("○ Pending")
245 };
246
247 output::numbered_item(i + 1, &format!("{} - {}", name, status));
248 }
249
250 output::newline();
251
252 let applied_count = migrations
253 .iter()
254 .filter(|m| is_migration_applied(m).unwrap_or(false))
255 .count();
256 let pending_count = migrations.len() - applied_count;
257
258 output::kv("Total", &migrations.len().to_string());
259 output::kv("Applied", &applied_count.to_string());
260 output::kv("Pending", &pending_count.to_string());
261
262 Ok(())
263}
264
265async fn run_resolve(args: crate::cli::MigrateResolveArgs) -> CliResult<()> {
267 output::header("Migrate Resolve");
268
269 if !args.applied && !args.rolled_back {
270 return Err(CliError::Command(
271 "Must specify --applied or --rolled-back".to_string(),
272 ));
273 }
274
275 Err(CliError::Migration(format!(
278 "migrate resolve is not yet implemented: marking migration '{}' as {} \
279 requires updating the _prax_migrations history table, which is not yet \
280 wired into the CLI. No changes were made.",
281 args.migration,
282 if args.applied {
283 "applied"
284 } else {
285 "rolled back"
286 }
287 )))
288}
289
290async fn run_diff(args: crate::cli::MigrateDiffArgs) -> CliResult<()> {
292 output::header("Migrate Diff");
293
294 let cwd = std::env::current_dir()?;
295 let config = load_config(&cwd)?;
296
297 if let Some(from_migration) = &args.from_migration {
302 return Err(CliError::Migration(format!(
303 "--from-migration '{}' is not supported: diffing against a specific \
304 migration requires a migration snapshot store, which is not yet \
305 wired into the CLI. Omit --from-migration to diff against the live \
306 database (or an empty schema when no database is reachable).",
307 from_migration
308 )));
309 }
310
311 output::step(1, 2, "Parsing schema...");
313 let loaded = crate::schema_loader::load_schema(args.schema.as_deref())?;
314 let schema = loaded.schema;
315
316 output::step(2, 2, "Comparing schema to database...");
319 let source = resolve_source_schema(&config).await?;
320 let ddl_sql = generate_migration_sql(&schema, source, &config, args.allow_destructive)?;
321
322 output::newline();
323 if ddl_sql.trim().is_empty() {
324 output::info("No changes: the database already matches the schema.");
325 }
326
327 output::newline();
328 output::section("Generated DDL");
329 output::code(&ddl_sql, "sql");
330
331 if let Some(output_path) = args.output {
332 std::fs::write(&output_path, &ddl_sql)?;
333 output::newline();
334 success(&format!("DDL written to {}", output_path.display()));
335 }
336
337 Ok(())
338}
339
340async fn run_rollback(args: crate::cli::MigrateRollbackArgs) -> CliResult<()> {
342 output::header("Migrate Rollback");
343
344 output::newline();
345
346 if let Some(to_migration) = &args.to {
347 output::info(&format!("Rolling back to migration: {}", to_migration));
348 } else {
349 output::info("Rolling back last applied migration...");
350 }
351
352 if let Some(reason) = &args.reason {
353 output::kv("Reason", reason);
354 }
355
356 if let Some(user) = &args.user {
357 output::kv("User", user);
358 }
359
360 output::newline();
361
362 Err(CliError::Migration(
373 "migrate rollback is not yet implemented: rolling back requires the \
374 prax-migrate event-sourcing engine (event store and down-migration \
375 execution), which is not yet wired into the CLI. No changes were made."
376 .to_string(),
377 ))
378}
379
380async fn run_history(args: crate::cli::MigrateHistoryArgs) -> CliResult<()> {
382 output::header("Migration History");
383
384 output::newline();
385
386 if let Some(migration) = &args.migration {
387 output::section(&format!("History for migration: {}", migration));
388 } else {
389 output::section("All migrations");
390 }
391
392 output::newline();
393
394 Err(CliError::Migration(
404 "migrate history is not yet implemented: viewing history requires reading \
405 the _prax_migrations event log, which is not yet wired into the CLI."
406 .to_string(),
407 ))
408}
409
410fn load_config(cwd: &Path) -> CliResult<Config> {
415 let config_path = cwd.join(CONFIG_FILE_NAME);
416 if config_path.exists() {
417 Config::load(&config_path)
418 } else {
419 Ok(Config::default())
420 }
421}
422
423fn check_pending_migrations(migrations_dir: &Path) -> CliResult<Vec<PathBuf>> {
424 let mut pending = Vec::new();
425
426 if !migrations_dir.exists() {
427 return Ok(pending);
428 }
429
430 for entry in std::fs::read_dir(migrations_dir)? {
431 let entry = entry?;
432 let path = entry.path();
433 if path.is_dir() && !is_migration_applied(&path)? {
434 pending.push(path);
435 }
436 }
437
438 pending.sort();
439 Ok(pending)
440}
441
442fn is_migration_applied(migration_path: &Path) -> CliResult<bool> {
443 let marker = migration_path.join(".applied");
446 Ok(marker.exists())
447}
448
449fn create_migration(migrations_dir: &Path, name: &str, sql: &str) -> CliResult<PathBuf> {
450 let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
452 let migration_name = format!("{}_{}", timestamp, name);
453 let migration_path = migrations_dir.join(&migration_name);
454
455 std::fs::create_dir_all(&migration_path)?;
456
457 let sql_path = migration_path.join("migration.sql");
459 std::fs::write(&sql_path, sql)?;
460
461 Ok(migration_path)
462}
463
464async fn resolve_source_schema(config: &Config) -> CliResult<Option<prax_schema::ast::Schema>> {
479 let Ok(database_url) = get_database_url(config) else {
481 output::list_item("No DATABASE_URL configured; treating as a new database.");
482 return Ok(None);
483 };
484
485 introspect_source_schema(config, &database_url).await
486}
487
488async fn introspect_source_schema(
502 config: &Config,
503 database_url: &str,
504) -> CliResult<Option<prax_schema::ast::Schema>> {
505 use crate::commands::introspect::{IntrospectionOptions, introspect_database};
506 use crate::commands::schema_from_db::schema_from_database;
507
508 let mut options = IntrospectionOptions::default();
513 if is_mysql(&config.database.provider) {
514 options.schema = mysql_database_from_url(database_url);
515 }
516
517 match introspect_database(&config.database.provider, database_url, &options).await {
518 Ok(db_schema) => {
519 let result = schema_from_database(&db_schema, Default::default())?;
520 for warning in &result.warnings {
521 output::warn(warning);
522 }
523 Ok(Some(result.schema))
524 }
525 Err(CliError::Unreachable(msg)) => {
529 output::warn(&format!(
530 "Database not reachable ({msg}); treating as a new database. \
531 Generated SQL will be full-creation DDL."
532 ));
533 Ok(None)
534 }
535 Err(CliError::FeatureUnavailable(msg)) => {
536 output::list_item(&format!("{msg} Using an empty source (full-creation DDL)."));
537 Ok(None)
538 }
539 Err(e) => Err(e),
540 }
541}
542
543fn is_mysql(provider: &str) -> bool {
545 matches!(provider.to_lowercase().as_str(), "mysql" | "mariadb")
546}
547
548fn mysql_database_from_url(url: &str) -> Option<String> {
555 let after_scheme = url.split("://").nth(1)?;
556 let host_and_path = match after_scheme.rsplit_once('@') {
559 Some((_userinfo, rest)) => rest,
560 None => after_scheme,
561 };
562 let after_authority = host_and_path.split_once('/')?.1;
563 let db = after_authority
564 .split(['?', '#'])
565 .next()
566 .unwrap_or("")
567 .trim_matches('/');
568 if db.is_empty() {
569 None
570 } else {
571 Some(db.to_string())
572 }
573}
574
575fn sql_backend_for_provider(provider: &str) -> CliResult<prax_migrate::SqlBackend> {
584 use prax_migrate::SqlBackend;
585 match provider.to_lowercase().as_str() {
586 "postgresql" | "postgres" | "pg" => Ok(SqlBackend::Postgres),
587 "mysql" | "mariadb" => Ok(SqlBackend::MySql),
588 "sqlite" | "sqlite3" => Ok(SqlBackend::Sqlite),
589 "mssql" | "sqlserver" | "sql_server" => Ok(SqlBackend::Mssql),
590 other => Err(CliError::Config(format!(
591 "Unsupported database provider for migration generation: '{}'",
592 other
593 ))),
594 }
595}
596
597fn generate_migration_sql(
605 schema: &prax_schema::ast::Schema,
606 source: Option<prax_schema::ast::Schema>,
607 config: &Config,
608 allow_destructive: bool,
609) -> CliResult<String> {
610 use prax_migrate::{SchemaDiffer, SqlDialect};
611
612 let backend = sql_backend_for_provider(&config.database.provider)?;
613
614 let differ = SchemaDiffer::new(schema.clone());
615 let differ = match source {
616 Some(src) => differ.with_source(src),
617 None => differ,
618 };
619
620 let mut diff = differ
621 .diff()
622 .map_err(|e| CliError::Migration(format!("Failed to diff schema against database: {e}")))?;
623
624 if !allow_destructive {
625 strip_destructive(&mut diff);
626 }
627
628 let migration = SqlDialect::for_backend(backend).generate_migration(&diff);
629
630 let mut out = String::from("-- Migration generated by Prax\n");
631 if !allow_destructive {
632 out.push_str(
633 "-- Additive-only: destructive statements (DROP) are omitted. Re-run with \
634 --allow-destructive to include them.\n",
635 );
636 }
637 for warning in &migration.warnings {
638 out.push_str(&format!("-- WARNING: {}\n", warning));
639 }
640 out.push('\n');
641 out.push_str(migration.up.trim_end());
642 if !migration.up.trim_end().is_empty() {
643 out.push('\n');
644 }
645
646 if migration.up.trim().is_empty() {
648 return Ok(String::new());
649 }
650
651 Ok(out)
652}
653
654fn strip_destructive(diff: &mut prax_migrate::SchemaDiff) {
663 diff.drop_models.clear();
664 diff.drop_enums.clear();
665 diff.drop_views.clear();
666 diff.drop_extensions.clear();
667 diff.drop_indexes.clear();
668
669 for alter in &mut diff.alter_models {
670 alter.drop_fields.clear();
671 alter.drop_indexes.clear();
672 alter.drop_foreign_keys.clear();
673 }
674 diff.alter_models.retain(|a| {
677 !a.add_fields.is_empty()
678 || !a.alter_fields.is_empty()
679 || !a.add_indexes.is_empty()
680 || !a.add_foreign_keys.is_empty()
681 });
682
683 for alter in &mut diff.alter_enums {
684 alter.remove_values.clear();
685 }
686 diff.alter_enums.retain(|a| !a.add_values.is_empty());
687
688 if let Some(procs) = diff.procedures.as_mut() {
692 procs.drop.clear();
693 procs.drop_triggers.clear();
694 if procs.is_empty() {
695 diff.procedures = None;
696 }
697 }
698}
699
700async fn apply_migration(migration_path: &Path, _config: &Config) -> CliResult<()> {
701 let sql_path = migration_path.join("migration.sql");
702
703 if !sql_path.exists() {
704 return Err(CliError::Migration(format!(
705 "Migration file not found: {}",
706 sql_path.display()
707 )));
708 }
709
710 Err(CliError::Migration(format!(
714 "Applying migration '{}' is not yet implemented: executing migration SQL \
715 requires a database executor that is not yet wired into the CLI. The \
716 migration SQL is at {}; apply it with an external tool for now.",
717 migration_path.display(),
718 sql_path.display()
719 )))
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
729 fn test_sql_backend_for_provider() {
730 use prax_migrate::SqlBackend;
731 assert_eq!(
732 sql_backend_for_provider("postgresql").unwrap(),
733 SqlBackend::Postgres
734 );
735 assert_eq!(
736 sql_backend_for_provider("postgres").unwrap(),
737 SqlBackend::Postgres
738 );
739 assert_eq!(
740 sql_backend_for_provider("mysql").unwrap(),
741 SqlBackend::MySql
742 );
743 assert_eq!(
744 sql_backend_for_provider("sqlite").unwrap(),
745 SqlBackend::Sqlite
746 );
747 assert!(sql_backend_for_provider("nonsense").is_err());
748 assert!(sql_backend_for_provider("duckdb").is_err());
751 }
752
753 #[test]
754 fn test_mysql_database_from_url() {
755 assert_eq!(
756 mysql_database_from_url("mysql://u:p@host:3306/mydb"),
757 Some("mydb".to_string())
758 );
759 assert_eq!(
760 mysql_database_from_url("mysql://u:p@host:3306/mydb?ssl=true"),
761 Some("mydb".to_string())
762 );
763 assert_eq!(mysql_database_from_url("mysql://u:p@host:3306"), None);
765 assert_eq!(mysql_database_from_url("mysql://u:p@host:3306/"), None);
766 assert_eq!(
768 mysql_database_from_url("mysql://u:p/w@host:3306/mydb"),
769 Some("mydb".to_string())
770 );
771 }
772
773 #[tokio::test]
774 async fn test_introspect_database_unsupported_provider_errors() {
775 use crate::commands::introspect::{IntrospectionOptions, introspect_database};
776 let opts = IntrospectionOptions::default();
779 match introspect_database("mongodb", "mongodb://x/y", &opts).await {
780 Err(CliError::Config(msg)) => {
781 assert!(
782 msg.contains("Unsupported database provider"),
783 "unexpected message: {msg}"
784 );
785 }
786 other => panic!("expected CliError::Config, got {other:?}"),
787 }
788 }
789
790 fn pg_config() -> Config {
793 Config::default()
794 }
795
796 fn parse(schema: &str) -> prax_schema::ast::Schema {
797 prax_schema::parse_schema(schema).expect("schema parses")
798 }
799
800 const USERS_V1: &str = r#"
801 model User {
802 id Int @id @auto
803 email String @unique
804
805 @@map("users")
806 }
807 "#;
808
809 #[test]
810 fn greenfield_generates_full_create_table() {
811 let schema = parse(USERS_V1);
813 let sql = generate_migration_sql(&schema, None, &pg_config(), false).unwrap();
814 assert!(sql.contains("CREATE TABLE \"users\""), "sql: {sql}");
815 assert!(sql.contains("SERIAL"), "auto id -> SERIAL: {sql}");
816 assert!(!sql.contains("IF NOT EXISTS"), "sql: {sql}");
818 }
819
820 #[test]
821 fn empty_diff_against_identical_source_yields_no_sql() {
822 let schema = parse(USERS_V1);
824 let source = parse(USERS_V1);
825 let sql = generate_migration_sql(&schema, Some(source), &pg_config(), false).unwrap();
826 assert!(sql.trim().is_empty(), "expected no changes, got: {sql}");
827 }
828
829 #[test]
830 fn incremental_diff_emits_only_the_delta() {
831 let source = parse(USERS_V1);
835 let target = parse(
836 r#"
837 model User {
838 id Int @id @auto
839 email String @unique
840 nickname String?
841
842 @@map("users")
843 }
844
845 model Membership {
846 userId Int @map("user_id")
847 teamId Int @map("team_id")
848 user User @relation(fields: [userId], references: [id])
849
850 @@id([userId, teamId])
851 @@map("memberships")
852 }
853 "#,
854 );
855
856 let sql = generate_migration_sql(&target, Some(source), &pg_config(), false).unwrap();
857
858 assert!(
860 sql.contains("ALTER TABLE \"users\" ADD COLUMN \"nickname\""),
861 "sql: {sql}"
862 );
863 assert!(sql.contains("CREATE TABLE \"memberships\""), "sql: {sql}");
865 assert!(
866 sql.contains("PRIMARY KEY (\"user_id\", \"team_id\")"),
867 "composite PK: {sql}"
868 );
869 assert!(sql.contains("FOREIGN KEY"), "fk: {sql}");
871 assert!(!sql.contains("CREATE TABLE \"users\""), "sql: {sql}");
873 }
874
875 #[test]
876 fn additive_only_strips_drops_by_default() {
877 let source = parse(
880 r#"
881 model User {
882 id Int @id @auto
883 email String @unique
884 obsolete String?
885
886 @@map("users")
887 }
888 model Legacy {
889 id Int @id @auto
890
891 @@map("legacy")
892 }
893 "#,
894 );
895 let target = parse(USERS_V1);
896
897 let sql =
898 generate_migration_sql(&target, Some(source.clone()), &pg_config(), false).unwrap();
899 assert!(
900 !sql.to_uppercase().contains("DROP"),
901 "additive-only must not drop: {sql}"
902 );
903
904 let sql_destructive =
906 generate_migration_sql(&target, Some(source), &pg_config(), true).unwrap();
907 assert!(
908 sql_destructive.contains("DROP TABLE") && sql_destructive.contains("DROP COLUMN"),
909 "destructive should drop: {sql_destructive}"
910 );
911 }
912
913 #[test]
914 fn destructive_emits_fk_and_index_drops_stripped_by_default() {
915 let source = parse(
919 r#"
920 model User {
921 id Int @id @auto
922 @@map("users")
923 }
924 model Post {
925 id Int @id @auto
926 authorId Int @map("author_id")
927 title String
928 author User @relation(fields: [authorId], references: [id], map: "post_author_fk")
929 @@index([title], map: "post_title_idx")
930 @@map("posts")
931 }
932 "#,
933 );
934 let target = parse(
936 r#"
937 model User {
938 id Int @id @auto
939 @@map("users")
940 }
941 model Post {
942 id Int @id @auto
943 authorId Int @map("author_id")
944 title String
945 @@map("posts")
946 }
947 "#,
948 );
949
950 let additive =
951 generate_migration_sql(&target, Some(source.clone()), &pg_config(), false).unwrap();
952 assert!(
953 !additive.to_uppercase().contains("DROP"),
954 "additive-only must strip FK/index drops: {additive}"
955 );
956
957 let destructive =
958 generate_migration_sql(&target, Some(source), &pg_config(), true).unwrap();
959 assert!(
960 destructive.contains("DROP CONSTRAINT") && destructive.contains("post_author_fk"),
961 "destructive should drop the FK constraint: {destructive}"
962 );
963 assert!(
964 destructive.contains("DROP INDEX") && destructive.contains("post_title_idx"),
965 "destructive should drop the index: {destructive}"
966 );
967 }
968
969 #[test]
970 fn strip_destructive_clears_procedure_and_trigger_drops() {
971 use prax_migrate::{ProcedureDiff, SchemaDiff};
972 let mut diff = SchemaDiff {
973 procedures: Some(ProcedureDiff {
974 drop: vec!["old_proc".into()],
975 drop_triggers: vec!["old_trigger".into()],
976 ..Default::default()
977 }),
978 ..Default::default()
979 };
980 strip_destructive(&mut diff);
981 assert!(
983 diff.procedures.is_none(),
984 "procedure drops must be stripped and the empty diff removed"
985 );
986 }
987
988 #[test]
989 fn diff_and_dev_share_identical_sql_for_same_inputs() {
990 let source = parse(USERS_V1);
994 let target = parse(
995 r#"
996 model User {
997 id Int @id @auto
998 email String @unique
999 nickname String?
1000
1001 @@map("users")
1002 }
1003 "#,
1004 );
1005
1006 let a = generate_migration_sql(&target, Some(source.clone()), &pg_config(), false).unwrap();
1007 let b = generate_migration_sql(&target, Some(source), &pg_config(), false).unwrap();
1008 assert_eq!(a, b, "same inputs must yield identical SQL");
1009 assert!(a.contains("ADD COLUMN \"nickname\""), "sql: {a}");
1010 }
1011
1012 #[test]
1013 fn strip_destructive_clears_all_drop_channels() {
1014 use prax_migrate::{EnumAlterDiff, ModelAlterDiff, SchemaDiff};
1015 let mut diff = SchemaDiff {
1016 drop_models: vec!["Legacy".into()],
1017 drop_enums: vec!["OldEnum".into()],
1018 drop_views: vec!["OldView".into()],
1019 drop_extensions: vec!["pgcrypto".into()],
1020 alter_enums: vec![EnumAlterDiff {
1021 name: "Status".into(),
1022 add_values: Vec::new(),
1023 remove_values: vec!["DEPRECATED".into()],
1024 }],
1025 alter_models: vec![ModelAlterDiff {
1026 name: "User".into(),
1027 table_name: "users".into(),
1028 add_fields: Vec::new(),
1029 drop_fields: vec!["obsolete".into()],
1030 alter_fields: Vec::new(),
1031 add_indexes: Vec::new(),
1032 drop_indexes: vec!["idx_old".into()],
1033 add_foreign_keys: Vec::new(),
1034 drop_foreign_keys: vec!["fk_old".into()],
1035 }],
1036 ..Default::default()
1037 };
1038
1039 strip_destructive(&mut diff);
1040
1041 assert!(diff.drop_models.is_empty());
1042 assert!(diff.drop_enums.is_empty());
1043 assert!(diff.drop_views.is_empty());
1044 assert!(diff.drop_extensions.is_empty());
1045 assert!(diff.alter_models.is_empty());
1047 assert!(diff.alter_enums.is_empty());
1049 }
1050
1051 #[tokio::test]
1054 async fn test_apply_migration_fails_without_executor() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let migration_path = dir.path().join("20240101000000_init");
1057 std::fs::create_dir_all(&migration_path).unwrap();
1058 std::fs::write(
1059 migration_path.join("migration.sql"),
1060 "CREATE TABLE t (id INT);",
1061 )
1062 .unwrap();
1063
1064 let result = apply_migration(&migration_path, &Config::default()).await;
1065
1066 match result {
1067 Err(CliError::Migration(msg)) => {
1068 assert!(
1069 msg.contains("not yet implemented"),
1070 "unexpected message: {msg}"
1071 );
1072 }
1073 other => panic!("expected CliError::Migration, got {other:?}"),
1074 }
1075
1076 assert!(!migration_path.join(".applied").exists());
1078 }
1079
1080 #[tokio::test]
1081 async fn test_apply_migration_missing_file() {
1082 let dir = tempfile::tempdir().unwrap();
1083
1084 match apply_migration(dir.path(), &Config::default()).await {
1085 Err(CliError::Migration(msg)) => assert!(msg.contains("not found")),
1086 other => panic!("expected CliError::Migration, got {other:?}"),
1087 }
1088 }
1089
1090 #[tokio::test]
1091 async fn test_run_reset_not_implemented() {
1092 let args = crate::cli::MigrateResetArgs {
1093 force: true,
1094 seed: false,
1095 skip_migrations: false,
1096 };
1097
1098 match run_reset(args).await {
1099 Err(CliError::Migration(msg)) => {
1100 assert!(
1101 msg.contains("not yet implemented"),
1102 "unexpected message: {msg}"
1103 );
1104 }
1105 other => panic!("expected CliError::Migration, got {other:?}"),
1106 }
1107 }
1108
1109 #[tokio::test]
1110 async fn test_run_resolve_not_implemented() {
1111 let args = crate::cli::MigrateResolveArgs {
1112 migration: "20240101000000_init".to_string(),
1113 applied: true,
1114 rolled_back: false,
1115 };
1116
1117 match run_resolve(args).await {
1118 Err(CliError::Migration(msg)) => {
1119 assert!(
1120 msg.contains("not yet implemented"),
1121 "unexpected message: {msg}"
1122 );
1123 assert!(msg.contains("20240101000000_init"));
1124 }
1125 other => panic!("expected CliError::Migration, got {other:?}"),
1126 }
1127 }
1128
1129 #[tokio::test]
1130 async fn test_run_resolve_requires_a_flag() {
1131 let args = crate::cli::MigrateResolveArgs {
1132 migration: "m".to_string(),
1133 applied: false,
1134 rolled_back: false,
1135 };
1136
1137 match run_resolve(args).await {
1138 Err(CliError::Command(msg)) => assert!(msg.contains("--applied or --rolled-back")),
1139 other => panic!("expected CliError::Command, got {other:?}"),
1140 }
1141 }
1142
1143 #[tokio::test]
1144 async fn test_run_rollback_not_implemented() {
1145 let args = crate::cli::MigrateRollbackArgs {
1146 reason: None,
1147 user: None,
1148 to: None,
1149 };
1150
1151 match run_rollback(args).await {
1152 Err(CliError::Migration(msg)) => {
1153 assert!(
1154 msg.contains("not yet implemented"),
1155 "unexpected message: {msg}"
1156 );
1157 }
1158 other => panic!("expected CliError::Migration, got {other:?}"),
1159 }
1160 }
1161
1162 #[tokio::test]
1163 async fn test_run_history_not_implemented() {
1164 let args = crate::cli::MigrateHistoryArgs { migration: None };
1165
1166 match run_history(args).await {
1167 Err(CliError::Migration(msg)) => {
1168 assert!(
1169 msg.contains("_prax_migrations"),
1170 "unexpected message: {msg}"
1171 );
1172 }
1173 other => panic!("expected CliError::Migration, got {other:?}"),
1174 }
1175 }
1176
1177 #[tokio::test]
1178 async fn test_run_diff_from_migration_unsupported() {
1179 let args = crate::cli::MigrateDiffArgs {
1180 schema: None,
1181 output: None,
1182 from_migration: Some("20240101000000_init".to_string()),
1183 allow_destructive: false,
1184 };
1185
1186 match run_diff(args).await {
1187 Err(CliError::Migration(msg)) => {
1188 assert!(
1189 msg.contains("--from-migration"),
1190 "unexpected message: {msg}"
1191 );
1192 }
1193 other => panic!("expected CliError::Migration, got {other:?}"),
1194 }
1195 }
1196}