Skip to main content

vespertide_macro/
lib.rs

1// MigrationOptions and MigrationError are now in vespertide-core
2
3use std::env;
4use std::path::PathBuf;
5
6use proc_macro::TokenStream;
7use quote::quote;
8use syn::parse::{Parse, ParseStream};
9use syn::{Expr, Ident, Token};
10use vespertide_loader::{
11    load_config_or_default, load_migrations_at_compile_time, load_models_at_compile_time,
12};
13use vespertide_planner::apply_action;
14use vespertide_query::{DatabaseBackend, build_plan_queries};
15
16struct MacroInput {
17    pool: Expr,
18    version_table: Option<String>,
19    verbose: bool,
20}
21
22impl Parse for MacroInput {
23    fn parse(input: ParseStream) -> syn::Result<Self> {
24        let pool = input.parse()?;
25        let mut version_table = None;
26        let mut verbose = false;
27
28        while !input.is_empty() {
29            input.parse::<Token![,]>()?;
30            if input.is_empty() {
31                break;
32            }
33
34            let key: Ident = input.parse()?;
35            if key == "version_table" {
36                input.parse::<Token![=]>()?;
37                let value: syn::LitStr = input.parse()?;
38                version_table = Some(value.value());
39            } else if key == "verbose" {
40                verbose = true;
41            } else {
42                return Err(syn::Error::new(
43                    key.span(),
44                    "unsupported option for vespertide_migration!",
45                ));
46            }
47        }
48
49        Ok(MacroInput {
50            pool,
51            version_table,
52            verbose,
53        })
54    }
55}
56
57pub(crate) fn build_migration_block(
58    migration: &vespertide_core::MigrationPlan,
59    baseline_schema: &mut Vec<vespertide_core::TableDef>,
60    verbose: bool,
61) -> Result<proc_macro2::TokenStream, String> {
62    let version = migration.version;
63    let migration_id = &migration.id;
64
65    // Use the current baseline schema (from all previous migrations)
66    let queries = build_plan_queries(migration, baseline_schema).map_err(|e| {
67        format!(
68            "Failed to build queries for migration version {}: {}",
69            version, e
70        )
71    })?;
72
73    // Update baseline schema incrementally by applying each action
74    for action in &migration.actions {
75        let _ = apply_action(baseline_schema, action);
76    }
77
78    // Generate version guard and SQL execution block
79    let version_str = format!("v{}", version);
80    let comment_str = migration.comment.as_deref().unwrap_or("").to_string();
81
82    let block = if verbose {
83        // Verbose mode: preserve per-action grouping with action descriptions
84        let total_sql_count: usize = queries
85            .iter()
86            .map(|q| q.postgres.len().max(q.mysql.len()).max(q.sqlite.len()))
87            .sum();
88        let total_sql_count_lit = total_sql_count;
89
90        let mut action_blocks = Vec::new();
91        let mut global_idx: usize = 0;
92
93        for (action_idx, q) in queries.iter().enumerate() {
94            let action_desc = format!("{}", q.action);
95            let action_num = action_idx + 1;
96            let total_actions = queries.len();
97
98            let pg: Vec<String> = q
99                .postgres
100                .iter()
101                .map(|s| s.build(DatabaseBackend::Postgres))
102                .collect();
103            let mysql: Vec<String> = q
104                .mysql
105                .iter()
106                .map(|s| s.build(DatabaseBackend::MySql))
107                .collect();
108            let sqlite: Vec<String> = q
109                .sqlite
110                .iter()
111                .map(|s| s.build(DatabaseBackend::Sqlite))
112                .collect();
113
114            // Build per-SQL execution with global index
115            let sql_count = pg.len().max(mysql.len()).max(sqlite.len());
116            let mut sql_exec_blocks = Vec::new();
117
118            for i in 0..sql_count {
119                let idx = global_idx + i + 1;
120                let pg_sql = pg.get(i).cloned().unwrap_or_default();
121                let mysql_sql = mysql.get(i).cloned().unwrap_or_default();
122                let sqlite_sql = sqlite.get(i).cloned().unwrap_or_default();
123
124                sql_exec_blocks.push(quote! {
125                    {
126                        let sql: &str = match backend {
127                            sea_orm::DatabaseBackend::Postgres => #pg_sql,
128                            sea_orm::DatabaseBackend::MySql => #mysql_sql,
129                            sea_orm::DatabaseBackend::Sqlite => #sqlite_sql,
130                            _ => #pg_sql,
131                        };
132                        if !sql.is_empty() {
133                            eprintln!("[vespertide]     [{}/{}] {}", #idx, #total_sql_count_lit, sql);
134                            let stmt = sea_orm::Statement::from_string(backend, sql);
135                            __txn.execute_raw(stmt).await.map_err(|e| {
136                                ::vespertide::MigrationError::DatabaseError(format!("Failed to execute SQL '{}': {}", sql, e))
137                            })?;
138                        }
139                    }
140                });
141            }
142            global_idx += sql_count;
143
144            action_blocks.push(quote! {
145                eprintln!("[vespertide]   Action {}/{}: {}", #action_num, #total_actions, #action_desc);
146                #(#sql_exec_blocks)*
147            });
148        }
149
150        quote! {
151            if __version < #version {
152                // Validate migration id against database if version already tracked
153                if let Some(db_id) = __version_ids.get(&#version) {
154                    let expected_id: &str = #migration_id;
155                    if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id {
156                        return Err(::vespertide::MigrationError::IdMismatch {
157                            version: #version,
158                            expected: expected_id.to_string(),
159                            found: db_id.clone(),
160                        });
161                    }
162                }
163
164                eprintln!("[vespertide] Applying migration {} ({})", #version_str, #comment_str);
165                #(#action_blocks)*
166
167                let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, #version, #migration_id);
168                let stmt = sea_orm::Statement::from_string(backend, insert_sql);
169                __txn.execute_raw(stmt).await.map_err(|e| {
170                    ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e))
171                })?;
172
173                eprintln!("[vespertide] Migration {} applied successfully", #version_str);
174            }
175        }
176    } else {
177        // Non-verbose: flatten all SQL into one array (minimal overhead)
178        let mut pg_sqls = Vec::new();
179        let mut mysql_sqls = Vec::new();
180        let mut sqlite_sqls = Vec::new();
181
182        for q in &queries {
183            for stmt in &q.postgres {
184                pg_sqls.push(stmt.build(DatabaseBackend::Postgres));
185            }
186            for stmt in &q.mysql {
187                mysql_sqls.push(stmt.build(DatabaseBackend::MySql));
188            }
189            for stmt in &q.sqlite {
190                sqlite_sqls.push(stmt.build(DatabaseBackend::Sqlite));
191            }
192        }
193
194        quote! {
195            if __version < #version {
196                // Validate migration id against database if version already tracked
197                if let Some(db_id) = __version_ids.get(&#version) {
198                    let expected_id: &str = #migration_id;
199                    if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id {
200                        return Err(::vespertide::MigrationError::IdMismatch {
201                            version: #version,
202                            expected: expected_id.to_string(),
203                            found: db_id.clone(),
204                        });
205                    }
206                }
207
208                let sqls: &[&str] = match backend {
209                    sea_orm::DatabaseBackend::Postgres => &[#(#pg_sqls),*],
210                    sea_orm::DatabaseBackend::MySql => &[#(#mysql_sqls),*],
211                    sea_orm::DatabaseBackend::Sqlite => &[#(#sqlite_sqls),*],
212                    _ => &[#(#pg_sqls),*],
213                };
214
215                for sql in sqls {
216                    if !sql.is_empty() {
217                        let stmt = sea_orm::Statement::from_string(backend, *sql);
218                        __txn.execute_raw(stmt).await.map_err(|e| {
219                            ::vespertide::MigrationError::DatabaseError(format!("Failed to execute SQL '{}': {}", sql, e))
220                        })?;
221                    }
222                }
223
224                let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, #version, #migration_id);
225                let stmt = sea_orm::Statement::from_string(backend, insert_sql);
226                __txn.execute_raw(stmt).await.map_err(|e| {
227                    ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e))
228                })?;
229            }
230        }
231    };
232
233    Ok(block)
234}
235
236fn generate_migration_code(
237    pool: &Expr,
238    version_table: &str,
239    migration_blocks: Vec<proc_macro2::TokenStream>,
240    verbose: bool,
241) -> proc_macro2::TokenStream {
242    let verbose_current_version = if verbose {
243        quote! {
244            eprintln!("[vespertide] Current database version: {}", __version);
245        }
246    } else {
247        quote! {}
248    };
249
250    quote! {
251        async {
252            use sea_orm::{ConnectionTrait, TransactionTrait};
253            let __pool = &#pool;
254            let __version_table = #version_table;
255            let backend = __pool.get_database_backend();
256            let q = if matches!(backend, sea_orm::DatabaseBackend::MySql) { '`' } else { '"' };
257
258            // Create version table if it does not exist (outside transaction)
259            let create_table_sql = format!(
260                "CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)",
261                __version_table
262            );
263            let stmt = sea_orm::Statement::from_string(backend, create_table_sql);
264            __pool.execute_raw(stmt).await.map_err(|e| {
265                ::vespertide::MigrationError::DatabaseError(format!("Failed to create version table: {}", e))
266            })?;
267
268            // Add id column for existing tables that don't have it yet (backward compatibility).
269            // We use a try-and-ignore approach: if the column already exists, the ALTER will fail
270            // and we simply ignore the error.
271            let alter_sql = format!(
272                "ALTER TABLE {q}{}{q} ADD COLUMN id TEXT DEFAULT ''",
273                __version_table
274            );
275            let stmt = sea_orm::Statement::from_string(backend, alter_sql);
276            let _ = __pool.execute_raw(stmt).await;
277
278            // Single transaction for the entire migration process.
279            // This prevents race conditions when multiple connections exist
280            // (e.g. SQLite with max_connections > 1).
281            let __txn = __pool.begin().await.map_err(|e| {
282                ::vespertide::MigrationError::DatabaseError(format!("Failed to begin transaction: {}", e))
283            })?;
284
285            // Read current maximum version inside the transaction (holds lock)
286            let select_sql = format!("SELECT MAX(version) as version FROM {q}{}{q}", __version_table);
287            let stmt = sea_orm::Statement::from_string(backend, select_sql);
288            let version_result = __txn.query_one_raw(stmt).await.map_err(|e| {
289                ::vespertide::MigrationError::DatabaseError(format!("Failed to read version: {}", e))
290            })?;
291
292            let __version = version_result
293                .and_then(|row| row.try_get::<i32>("", "version").ok())
294                .unwrap_or(0) as u32;
295
296            // Load all existing (version, id) pairs for id mismatch validation
297            let select_ids_sql = format!("SELECT version, id FROM {q}{}{q}", __version_table);
298            let stmt = sea_orm::Statement::from_string(backend, select_ids_sql);
299            let id_rows = __txn.query_all_raw(stmt).await.map_err(|e| {
300                ::vespertide::MigrationError::DatabaseError(format!("Failed to read version ids: {}", e))
301            })?;
302
303            let mut __version_ids = std::collections::HashMap::<u32, String>::new();
304            for row in &id_rows {
305                if let Ok(v) = row.try_get::<i32>("", "version") {
306                    let id = row.try_get::<String>("", "id").unwrap_or_default();
307                    __version_ids.insert(v as u32, id);
308                }
309            }
310
311            #verbose_current_version
312
313            // Execute each migration block within the same transaction
314            #(#migration_blocks)*
315
316            // Commit the entire migration
317            __txn.commit().await.map_err(|e| {
318                ::vespertide::MigrationError::DatabaseError(format!("Failed to commit transaction: {}", e))
319            })?;
320
321            Ok::<(), ::vespertide::MigrationError>(())
322        }
323    }
324}
325
326/// Inner implementation that works with proc_macro2::TokenStream for testability.
327pub(crate) fn vespertide_migration_impl(
328    input: proc_macro2::TokenStream,
329) -> proc_macro2::TokenStream {
330    let input: MacroInput = match syn::parse2(input) {
331        Ok(input) => input,
332        Err(e) => return e.to_compile_error(),
333    };
334    let pool = &input.pool;
335    let verbose = input.verbose;
336
337    // Get project root from CARGO_MANIFEST_DIR (same as load_migrations_at_compile_time)
338    let project_root = match env::var("CARGO_MANIFEST_DIR") {
339        Ok(dir) => Some(PathBuf::from(dir)),
340        Err(_) => None,
341    };
342
343    // Load config to get prefix
344    let config = match load_config_or_default(project_root) {
345        Ok(config) => config,
346        #[cfg(not(tarpaulin_include))]
347        Err(e) => {
348            return syn::Error::new(
349                proc_macro2::Span::call_site(),
350                format!("Failed to load config at compile time: {}", e),
351            )
352            .to_compile_error();
353        }
354    };
355    let prefix = config.prefix();
356
357    // Apply prefix to version_table if not explicitly provided
358    let version_table = input
359        .version_table
360        .map(|vt| config.apply_prefix(&vt))
361        .unwrap_or_else(|| config.apply_prefix("vespertide_version"));
362
363    // Load migration files and build SQL at compile time
364    let migrations = match load_migrations_at_compile_time() {
365        Ok(migrations) => migrations,
366        Err(e) => {
367            return syn::Error::new(
368                proc_macro2::Span::call_site(),
369                format!("Failed to load migrations at compile time: {}", e),
370            )
371            .to_compile_error();
372        }
373    };
374    let _models = match load_models_at_compile_time() {
375        Ok(models) => models,
376        #[cfg(not(tarpaulin_include))]
377        Err(e) => {
378            return syn::Error::new(
379                proc_macro2::Span::call_site(),
380                format!("Failed to load models at compile time: {}", e),
381            )
382            .to_compile_error();
383        }
384    };
385
386    // Apply prefix to migrations and build SQL using incremental baseline schema
387    let mut baseline_schema = Vec::new();
388    let mut migration_blocks = Vec::new();
389
390    #[cfg(not(tarpaulin_include))]
391    for migration in &migrations {
392        // Apply prefix to migration table names
393        let prefixed_migration = migration.clone().with_prefix(prefix);
394        match build_migration_block(&prefixed_migration, &mut baseline_schema, verbose) {
395            Ok(block) => migration_blocks.push(block),
396            Err(e) => {
397                return syn::Error::new(proc_macro2::Span::call_site(), e).to_compile_error();
398            }
399        }
400    }
401
402    generate_migration_code(pool, &version_table, migration_blocks, verbose)
403}
404
405/// Zero-runtime migration entry point.
406#[cfg(not(tarpaulin_include))]
407#[proc_macro]
408pub fn vespertide_migration(input: TokenStream) -> TokenStream {
409    vespertide_migration_impl(input.into()).into()
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use std::fs::File;
416    use std::io::Write;
417    use tempfile::tempdir;
418    use vespertide_core::{
419        ColumnDef, ColumnType, MigrationAction, MigrationPlan, SimpleColumnType, StrOrBoolOrArray,
420    };
421
422    #[test]
423    fn test_macro_expansion_with_runtime_macros() {
424        // Create a temporary directory with test files
425        let dir = tempdir().unwrap();
426
427        // Create a test file that uses the macro
428        let test_file_path = dir.path().join("test_macro.rs");
429        let mut test_file = File::create(&test_file_path).unwrap();
430        writeln!(
431            test_file,
432            r#"vespertide_migration!(pool, version_table = "test_versions");"#
433        )
434        .unwrap();
435
436        // Use runtime-macros to emulate macro expansion
437        let file = File::open(&test_file_path).unwrap();
438        let result = runtime_macros::emulate_functionlike_macro_expansion(
439            file,
440            &[("vespertide_migration", vespertide_migration_impl)],
441        );
442
443        // The macro will fail because there's no vespertide config, but
444        // the important thing is that it runs and covers the macro code
445        // We expect an error due to missing config
446        assert!(result.is_ok() || result.is_err());
447    }
448
449    #[test]
450    fn test_macro_with_simple_pool() {
451        let dir = tempdir().unwrap();
452        let test_file_path = dir.path().join("test_simple.rs");
453        let mut test_file = File::create(&test_file_path).unwrap();
454        writeln!(test_file, r#"vespertide_migration!(db_pool);"#).unwrap();
455
456        let file = File::open(&test_file_path).unwrap();
457        let result = runtime_macros::emulate_functionlike_macro_expansion(
458            file,
459            &[("vespertide_migration", vespertide_migration_impl)],
460        );
461
462        assert!(result.is_ok() || result.is_err());
463    }
464
465    #[test]
466    fn test_macro_parsing_invalid_option() {
467        // Test that invalid options produce a compile error
468        let input: proc_macro2::TokenStream = "pool, invalid_option = \"value\"".parse().unwrap();
469        let output = vespertide_migration_impl(input);
470        let output_str = output.to_string();
471        // Should contain an error message about unsupported option
472        assert!(output_str.contains("unsupported option"));
473    }
474
475    #[test]
476    fn test_macro_parsing_valid_input() {
477        // Test that valid input is parsed correctly
478        // The macro will either succeed (if migrations dir exists and is empty)
479        // or fail with a migration loading error
480        let input: proc_macro2::TokenStream = "my_pool".parse().unwrap();
481        let output = vespertide_migration_impl(input);
482        let output_str = output.to_string();
483        // Should produce output (either success or migration loading error)
484        assert!(!output_str.is_empty());
485        // If error, it should mention "Failed to load"
486        // If success, it should contain "async"
487        assert!(
488            output_str.contains("async") || output_str.contains("Failed to load"),
489            "Unexpected output: {}",
490            output_str
491        );
492    }
493
494    #[test]
495    fn test_macro_parsing_with_version_table() {
496        let input: proc_macro2::TokenStream =
497            r#"pool, version_table = "custom_versions""#.parse().unwrap();
498        let output = vespertide_migration_impl(input);
499        let output_str = output.to_string();
500        assert!(!output_str.is_empty());
501    }
502
503    #[test]
504    fn test_macro_parsing_trailing_comma() {
505        let input: proc_macro2::TokenStream = "pool,".parse().unwrap();
506        let output = vespertide_migration_impl(input);
507        let output_str = output.to_string();
508        assert!(!output_str.is_empty());
509    }
510
511    fn test_column(name: &str) -> ColumnDef {
512        ColumnDef {
513            name: name.into(),
514            r#type: ColumnType::Simple(SimpleColumnType::Integer),
515            nullable: false,
516            default: None,
517            comment: None,
518            primary_key: None,
519            unique: None,
520            index: None,
521            foreign_key: None,
522        }
523    }
524
525    #[test]
526    fn test_build_migration_block_create_table() {
527        let migration = MigrationPlan {
528            id: String::new(),
529            version: 1,
530            comment: None,
531            created_at: None,
532            actions: vec![MigrationAction::CreateTable {
533                table: "users".into(),
534                columns: vec![test_column("id")],
535                constraints: vec![],
536            }],
537        };
538
539        let mut baseline = Vec::new();
540        let result = build_migration_block(&migration, &mut baseline, false);
541
542        assert!(result.is_ok());
543        let block = result.unwrap();
544        let block_str = block.to_string();
545
546        // Verify the generated block contains expected elements
547        assert!(block_str.contains("version < 1u32"));
548        assert!(block_str.contains("CREATE TABLE"));
549
550        // Verify baseline schema was updated
551        assert_eq!(baseline.len(), 1);
552        assert_eq!(baseline[0].name, "users");
553    }
554
555    #[test]
556    fn test_build_migration_block_add_column() {
557        // First create the table
558        let create_migration = MigrationPlan {
559            id: String::new(),
560            version: 1,
561            comment: None,
562            created_at: None,
563            actions: vec![MigrationAction::CreateTable {
564                table: "users".into(),
565                columns: vec![test_column("id")],
566                constraints: vec![],
567            }],
568        };
569
570        let mut baseline = Vec::new();
571        let _ = build_migration_block(&create_migration, &mut baseline, false);
572
573        // Now add a column
574        let add_column_migration = MigrationPlan {
575            id: String::new(),
576            version: 2,
577            comment: None,
578            created_at: None,
579            actions: vec![MigrationAction::AddColumn {
580                table: "users".into(),
581                column: Box::new(ColumnDef {
582                    name: "email".into(),
583                    r#type: ColumnType::Simple(SimpleColumnType::Text),
584                    nullable: true,
585                    default: None,
586                    comment: None,
587                    primary_key: None,
588                    unique: None,
589                    index: None,
590                    foreign_key: None,
591                }),
592                fill_with: None,
593            }],
594        };
595
596        let result = build_migration_block(&add_column_migration, &mut baseline, false);
597        assert!(result.is_ok());
598        let block = result.unwrap();
599        let block_str = block.to_string();
600
601        assert!(block_str.contains("version < 2u32"));
602        assert!(block_str.contains("ALTER TABLE"));
603        assert!(block_str.contains("ADD COLUMN"));
604    }
605
606    #[test]
607    fn test_build_migration_block_multiple_actions() {
608        let migration = MigrationPlan {
609            id: String::new(),
610            version: 1,
611            comment: None,
612            created_at: None,
613            actions: vec![
614                MigrationAction::CreateTable {
615                    table: "users".into(),
616                    columns: vec![test_column("id")],
617                    constraints: vec![],
618                },
619                MigrationAction::CreateTable {
620                    table: "posts".into(),
621                    columns: vec![test_column("id")],
622                    constraints: vec![],
623                },
624            ],
625        };
626
627        let mut baseline = Vec::new();
628        let result = build_migration_block(&migration, &mut baseline, false);
629
630        assert!(result.is_ok());
631        assert_eq!(baseline.len(), 2);
632    }
633
634    #[test]
635    fn test_generate_migration_code() {
636        let pool: Expr = syn::parse_str("db_pool").unwrap();
637        let version_table = "test_versions";
638
639        // Create a simple migration block
640        let migration = MigrationPlan {
641            id: String::new(),
642            version: 1,
643            comment: None,
644            created_at: None,
645            actions: vec![MigrationAction::CreateTable {
646                table: "users".into(),
647                columns: vec![test_column("id")],
648                constraints: vec![],
649            }],
650        };
651
652        let mut baseline = Vec::new();
653        let block = build_migration_block(&migration, &mut baseline, false).unwrap();
654
655        let generated = generate_migration_code(&pool, version_table, vec![block], false);
656        let generated_str = generated.to_string();
657
658        // Verify the generated code structure
659        assert!(generated_str.contains("async"));
660        assert!(generated_str.contains("db_pool"));
661        assert!(generated_str.contains("test_versions"));
662        assert!(generated_str.contains("CREATE TABLE IF NOT EXISTS"));
663        assert!(generated_str.contains("SELECT MAX"));
664    }
665
666    #[test]
667    fn test_generate_migration_code_empty_migrations() {
668        let pool: Expr = syn::parse_str("pool").unwrap();
669        let version_table = "vespertide_version";
670
671        let generated = generate_migration_code(&pool, version_table, vec![], false);
672        let generated_str = generated.to_string();
673
674        // Should still generate the wrapper code
675        assert!(generated_str.contains("async"));
676        assert!(generated_str.contains("vespertide_version"));
677    }
678
679    #[test]
680    fn test_generate_migration_code_multiple_blocks() {
681        let pool: Expr = syn::parse_str("connection").unwrap();
682
683        let mut baseline = Vec::new();
684
685        let migration1 = MigrationPlan {
686            id: String::new(),
687            version: 1,
688            comment: None,
689            created_at: None,
690            actions: vec![MigrationAction::CreateTable {
691                table: "users".into(),
692                columns: vec![test_column("id")],
693                constraints: vec![],
694            }],
695        };
696        let block1 = build_migration_block(&migration1, &mut baseline, false).unwrap();
697
698        let migration2 = MigrationPlan {
699            id: String::new(),
700            version: 2,
701            comment: None,
702            created_at: None,
703            actions: vec![MigrationAction::CreateTable {
704                table: "posts".into(),
705                columns: vec![test_column("id")],
706                constraints: vec![],
707            }],
708        };
709        let block2 = build_migration_block(&migration2, &mut baseline, false).unwrap();
710
711        let generated = generate_migration_code(&pool, "migrations", vec![block1, block2], false);
712        let generated_str = generated.to_string();
713
714        // Both version checks should be present
715        assert!(generated_str.contains("version < 1u32"));
716        assert!(generated_str.contains("version < 2u32"));
717    }
718
719    #[test]
720    fn test_build_migration_block_generates_all_backends() {
721        let migration = MigrationPlan {
722            id: String::new(),
723            version: 1,
724            comment: None,
725            created_at: None,
726            actions: vec![MigrationAction::CreateTable {
727                table: "test_table".into(),
728                columns: vec![test_column("id")],
729                constraints: vec![],
730            }],
731        };
732
733        let mut baseline = Vec::new();
734        let result = build_migration_block(&migration, &mut baseline, false);
735        assert!(result.is_ok());
736
737        let block_str = result.unwrap().to_string();
738
739        // The generated block should have backend matching
740        assert!(block_str.contains("DatabaseBackend :: Postgres"));
741        assert!(block_str.contains("DatabaseBackend :: MySql"));
742        assert!(block_str.contains("DatabaseBackend :: Sqlite"));
743    }
744
745    #[test]
746    fn test_build_migration_block_with_delete_table() {
747        // First create the table
748        let create_migration = MigrationPlan {
749            id: String::new(),
750            version: 1,
751            comment: None,
752            created_at: None,
753            actions: vec![MigrationAction::CreateTable {
754                table: "temp_table".into(),
755                columns: vec![test_column("id")],
756                constraints: vec![],
757            }],
758        };
759
760        let mut baseline = Vec::new();
761        let _ = build_migration_block(&create_migration, &mut baseline, false);
762        assert_eq!(baseline.len(), 1);
763
764        // Now delete it
765        let delete_migration = MigrationPlan {
766            id: String::new(),
767            version: 2,
768            comment: None,
769            created_at: None,
770            actions: vec![MigrationAction::DeleteTable {
771                table: "temp_table".into(),
772            }],
773        };
774
775        let result = build_migration_block(&delete_migration, &mut baseline, false);
776        assert!(result.is_ok());
777        let block_str = result.unwrap().to_string();
778        assert!(block_str.contains("DROP TABLE"));
779
780        // Baseline should be empty after delete
781        assert_eq!(baseline.len(), 0);
782    }
783
784    #[test]
785    fn test_build_migration_block_with_index() {
786        let migration = MigrationPlan {
787            id: String::new(),
788            version: 1,
789            comment: None,
790            created_at: None,
791            actions: vec![MigrationAction::CreateTable {
792                table: "users".into(),
793                columns: vec![
794                    test_column("id"),
795                    ColumnDef {
796                        name: "email".into(),
797                        r#type: ColumnType::Simple(SimpleColumnType::Text),
798                        nullable: true,
799                        default: None,
800                        comment: None,
801                        primary_key: None,
802                        unique: None,
803                        index: Some(StrOrBoolOrArray::Bool(true)),
804                        foreign_key: None,
805                    },
806                ],
807                constraints: vec![],
808            }],
809        };
810
811        let mut baseline = Vec::new();
812        let result = build_migration_block(&migration, &mut baseline, false);
813        assert!(result.is_ok());
814
815        // Table should be normalized with index
816        let table = &baseline[0];
817        let normalized = table.clone().normalize();
818        assert!(normalized.is_ok());
819    }
820
821    #[test]
822    fn test_build_migration_block_error_nonexistent_table() {
823        // Try to add column to a table that doesn't exist - should fail
824        let migration = MigrationPlan {
825            id: String::new(),
826            version: 1,
827            comment: None,
828            created_at: None,
829            actions: vec![MigrationAction::AddColumn {
830                table: "nonexistent_table".into(),
831                column: Box::new(test_column("new_col")),
832                fill_with: None,
833            }],
834        };
835
836        let mut baseline = Vec::new();
837        let result = build_migration_block(&migration, &mut baseline, false);
838
839        assert!(result.is_err());
840        let err = result.unwrap_err();
841        assert!(err.contains("Failed to build queries for migration version 1"));
842    }
843
844    #[test]
845    fn test_vespertide_migration_impl_loading_error() {
846        // Save original CARGO_MANIFEST_DIR
847        let original = std::env::var("CARGO_MANIFEST_DIR").ok();
848
849        // Remove CARGO_MANIFEST_DIR to trigger loading error
850        unsafe {
851            std::env::remove_var("CARGO_MANIFEST_DIR");
852        }
853
854        let input: proc_macro2::TokenStream = "pool".parse().unwrap();
855        let output = vespertide_migration_impl(input);
856        let output_str = output.to_string();
857
858        // Should contain error about failed loading
859        assert!(
860            output_str.contains("Failed to load migrations at compile time"),
861            "Expected loading error, got: {}",
862            output_str
863        );
864
865        // Restore CARGO_MANIFEST_DIR
866        if let Some(val) = original {
867            unsafe {
868                std::env::set_var("CARGO_MANIFEST_DIR", val);
869            }
870        }
871    }
872
873    #[test]
874    fn test_vespertide_migration_impl_with_valid_project() {
875        use std::fs;
876
877        // Create a temporary directory with a valid vespertide project
878        let dir = tempdir().unwrap();
879        let project_dir = dir.path();
880
881        // Create vespertide.json config
882        let config_content = r#"{
883            "modelsDir": "models",
884            "migrationsDir": "migrations",
885            "tableNamingCase": "snake",
886            "columnNamingCase": "snake",
887            "modelFormat": "json"
888        }"#;
889        fs::write(project_dir.join("vespertide.json"), config_content).unwrap();
890
891        // Create empty models and migrations directories
892        fs::create_dir_all(project_dir.join("models")).unwrap();
893        fs::create_dir_all(project_dir.join("migrations")).unwrap();
894
895        // Save original CARGO_MANIFEST_DIR and set to temp dir
896        let original = std::env::var("CARGO_MANIFEST_DIR").ok();
897        unsafe {
898            std::env::set_var("CARGO_MANIFEST_DIR", project_dir);
899        }
900
901        let input: proc_macro2::TokenStream = "pool".parse().unwrap();
902        let output = vespertide_migration_impl(input);
903        let output_str = output.to_string();
904
905        // Should produce valid async code since there are no migrations
906        assert!(
907            output_str.contains("async"),
908            "Expected async block, got: {}",
909            output_str
910        );
911        assert!(
912            output_str.contains("CREATE TABLE IF NOT EXISTS"),
913            "Expected version table creation, got: {}",
914            output_str
915        );
916
917        // Restore CARGO_MANIFEST_DIR
918        if let Some(val) = original {
919            unsafe {
920                std::env::set_var("CARGO_MANIFEST_DIR", val);
921            }
922        } else {
923            unsafe {
924                std::env::remove_var("CARGO_MANIFEST_DIR");
925            }
926        }
927    }
928
929    #[test]
930    fn test_build_migration_block_verbose_create_table() {
931        let migration = MigrationPlan {
932            id: String::new(),
933            version: 1,
934            comment: Some("initial setup".into()),
935            created_at: None,
936            actions: vec![MigrationAction::CreateTable {
937                table: "users".into(),
938                columns: vec![test_column("id")],
939                constraints: vec![],
940            }],
941        };
942
943        let mut baseline = Vec::new();
944        let result = build_migration_block(&migration, &mut baseline, true);
945
946        assert!(result.is_ok());
947        let block_str = result.unwrap().to_string();
948
949        // Verbose mode should contain eprintln statements with action descriptions
950        assert!(block_str.contains("vespertide"));
951        assert!(block_str.contains("Action"));
952        assert!(block_str.contains("version < 1u32"));
953    }
954
955    #[test]
956    fn test_build_migration_block_verbose_multiple_actions() {
957        let migration = MigrationPlan {
958            id: String::new(),
959            version: 1,
960            comment: None,
961            created_at: None,
962            actions: vec![
963                MigrationAction::CreateTable {
964                    table: "users".into(),
965                    columns: vec![test_column("id")],
966                    constraints: vec![],
967                },
968                MigrationAction::CreateTable {
969                    table: "posts".into(),
970                    columns: vec![test_column("id")],
971                    constraints: vec![],
972                },
973            ],
974        };
975
976        let mut baseline = Vec::new();
977        let result = build_migration_block(&migration, &mut baseline, true);
978
979        assert!(result.is_ok());
980        let block_str = result.unwrap().to_string();
981
982        // Should have action numbering for both actions
983        assert!(block_str.contains("Action"));
984        assert_eq!(baseline.len(), 2);
985    }
986
987    #[test]
988    fn test_build_migration_block_verbose_add_column() {
989        // Create table first
990        let create = MigrationPlan {
991            id: String::new(),
992            version: 1,
993            comment: None,
994            created_at: None,
995            actions: vec![MigrationAction::CreateTable {
996                table: "users".into(),
997                columns: vec![test_column("id")],
998                constraints: vec![],
999            }],
1000        };
1001        let mut baseline = Vec::new();
1002        let _ = build_migration_block(&create, &mut baseline, true);
1003
1004        // Add column in verbose mode
1005        let add_col = MigrationPlan {
1006            id: String::new(),
1007            version: 2,
1008            comment: Some("add email".into()),
1009            created_at: None,
1010            actions: vec![MigrationAction::AddColumn {
1011                table: "users".into(),
1012                column: Box::new(ColumnDef {
1013                    name: "email".into(),
1014                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1015                    nullable: true,
1016                    default: None,
1017                    comment: None,
1018                    primary_key: None,
1019                    unique: None,
1020                    index: None,
1021                    foreign_key: None,
1022                }),
1023                fill_with: None,
1024            }],
1025        };
1026
1027        let result = build_migration_block(&add_col, &mut baseline, true);
1028        assert!(result.is_ok());
1029        let block_str = result.unwrap().to_string();
1030        assert!(block_str.contains("vespertide"));
1031        assert!(block_str.contains("version < 2u32"));
1032    }
1033
1034    #[test]
1035    fn test_generate_migration_code_verbose() {
1036        let pool: Expr = syn::parse_str("db_pool").unwrap();
1037        let version_table = "test_versions";
1038
1039        let migration = MigrationPlan {
1040            id: String::new(),
1041            version: 1,
1042            comment: None,
1043            created_at: None,
1044            actions: vec![MigrationAction::CreateTable {
1045                table: "users".into(),
1046                columns: vec![test_column("id")],
1047                constraints: vec![],
1048            }],
1049        };
1050
1051        let mut baseline = Vec::new();
1052        let block = build_migration_block(&migration, &mut baseline, true).unwrap();
1053
1054        let generated = generate_migration_code(&pool, version_table, vec![block], true);
1055        let generated_str = generated.to_string();
1056
1057        // Verbose mode should include current version eprintln
1058        assert!(generated_str.contains("Current database version"));
1059        assert!(generated_str.contains("async"));
1060    }
1061
1062    #[test]
1063    fn test_macro_parsing_verbose_flag() {
1064        // Test parsing the "verbose" keyword
1065        let input: proc_macro2::TokenStream = "pool, verbose".parse().unwrap();
1066        let output = vespertide_migration_impl(input);
1067        let output_str = output.to_string();
1068        // Should produce output (either success or migration loading error)
1069        assert!(!output_str.is_empty());
1070    }
1071
1072    #[test]
1073    fn test_vespertide_migration_impl_with_migrations() {
1074        use std::fs;
1075
1076        // Create a temporary directory with a valid vespertide project and migrations
1077        let dir = tempdir().unwrap();
1078        let project_dir = dir.path();
1079
1080        // Create vespertide.json config
1081        let config_content = r#"{
1082            "modelsDir": "models",
1083            "migrationsDir": "migrations",
1084            "tableNamingCase": "snake",
1085            "columnNamingCase": "snake",
1086            "modelFormat": "json"
1087        }"#;
1088        fs::write(project_dir.join("vespertide.json"), config_content).unwrap();
1089
1090        // Create models and migrations directories
1091        fs::create_dir_all(project_dir.join("models")).unwrap();
1092        fs::create_dir_all(project_dir.join("migrations")).unwrap();
1093
1094        // Create a migration file
1095        let migration_content = r#"{
1096            "version": 1,
1097            "actions": [
1098                {
1099                    "type": "create_table",
1100                    "table": "users",
1101                    "columns": [
1102                        {"name": "id", "type": "integer", "nullable": false}
1103                    ],
1104                    "constraints": []
1105                }
1106            ]
1107        }"#;
1108        fs::write(
1109            project_dir.join("migrations").join("0001_initial.json"),
1110            migration_content,
1111        )
1112        .unwrap();
1113
1114        // Save original CARGO_MANIFEST_DIR and set to temp dir
1115        let original = std::env::var("CARGO_MANIFEST_DIR").ok();
1116        unsafe {
1117            std::env::set_var("CARGO_MANIFEST_DIR", project_dir);
1118        }
1119
1120        let input: proc_macro2::TokenStream = "pool".parse().unwrap();
1121        let output = vespertide_migration_impl(input);
1122        let output_str = output.to_string();
1123
1124        // Should produce valid async code with migration
1125        assert!(
1126            output_str.contains("async"),
1127            "Expected async block, got: {}",
1128            output_str
1129        );
1130
1131        // Restore CARGO_MANIFEST_DIR
1132        if let Some(val) = original {
1133            unsafe {
1134                std::env::set_var("CARGO_MANIFEST_DIR", val);
1135            }
1136        } else {
1137            unsafe {
1138                std::env::remove_var("CARGO_MANIFEST_DIR");
1139            }
1140        }
1141    }
1142}