Skip to main content

forge_runtime/migrations/
generator.rs

1use chrono::{DateTime, Utc};
2use forge_core::schema::TableDef;
3
4use super::diff::{DatabaseTable, SchemaDiff};
5
6/// Generates SQL migrations from schema changes.
7pub struct MigrationGenerator {
8    /// Output directory for migrations.
9    output_dir: std::path::PathBuf,
10}
11
12impl MigrationGenerator {
13    /// Create a new migration generator.
14    pub fn new(output_dir: impl Into<std::path::PathBuf>) -> Self {
15        Self {
16            output_dir: output_dir.into(),
17        }
18    }
19
20    /// Generate a migration from schema diff.
21    pub fn generate(
22        &self,
23        rust_tables: &[TableDef],
24        db_tables: &[DatabaseTable],
25    ) -> Result<Option<Migration>, GeneratorError> {
26        let diff = SchemaDiff::from_comparison(rust_tables, db_tables);
27
28        if diff.is_empty() {
29            return Ok(None);
30        }
31
32        let now = Utc::now();
33        let version = now.format("%Y%m%d_%H%M%S").to_string();
34        let name = self.generate_name(&diff);
35
36        let sql = diff.to_sql().join("\n\n");
37
38        let migration = Migration {
39            version: version.clone(),
40            name: name.clone(),
41            sql,
42            created_at: now,
43            path: self.output_dir.join(format!("{}_{}.sql", version, name)),
44        };
45
46        Ok(Some(migration))
47    }
48
49    /// Generate a human-readable name for the migration.
50    fn generate_name(&self, diff: &SchemaDiff) -> String {
51        if diff.entries.is_empty() {
52            return "empty".to_string();
53        }
54
55        // Use first entry to generate name
56        let first = &diff.entries[0];
57        match first.action {
58            super::diff::DiffAction::CreateTable => {
59                format!("create_{}", first.table_name)
60            }
61            super::diff::DiffAction::AddColumn => {
62                format!("add_column_to_{}", first.table_name)
63            }
64            super::diff::DiffAction::DropColumn => {
65                format!("remove_column_from_{}", first.table_name)
66            }
67            super::diff::DiffAction::DropTable => {
68                format!("drop_{}", first.table_name)
69            }
70            _ => "schema_update".to_string(),
71        }
72    }
73
74    /// Write migration to disk.
75    pub fn write_migration(&self, migration: &Migration) -> Result<(), GeneratorError> {
76        std::fs::create_dir_all(&self.output_dir).map_err(|e| GeneratorError::Io(e.to_string()))?;
77
78        let content = format!(
79            "-- Migration: {}\n-- Generated at: {}\n\n{}\n",
80            migration.name,
81            migration.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
82            migration.sql
83        );
84
85        std::fs::write(&migration.path, content).map_err(|e| GeneratorError::Io(e.to_string()))?;
86
87        Ok(())
88    }
89}
90
91/// A generated migration.
92#[derive(Debug, Clone)]
93pub struct Migration {
94    /// Version identifier (timestamp-based).
95    pub version: String,
96    /// Human-readable name.
97    pub name: String,
98    /// SQL content.
99    pub sql: String,
100    /// When the migration was created.
101    pub created_at: DateTime<Utc>,
102    /// Path to the migration file.
103    pub path: std::path::PathBuf,
104}
105
106/// Migration generator errors.
107#[derive(Debug, thiserror::Error)]
108pub enum GeneratorError {
109    #[error("IO error: {0}")]
110    Io(String),
111    #[error("Parse error: {0}")]
112    Parse(String),
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use forge_core::schema::RustType;
119    use forge_core::schema::{FieldDef, TableDef};
120
121    #[test]
122    fn test_generate_migration() {
123        let generator = MigrationGenerator::new("/tmp/migrations");
124
125        let mut table = TableDef::new("users", "User");
126        table.fields.push(FieldDef::new("id", RustType::Uuid));
127
128        let migration = generator.generate(&[table], &[]).unwrap();
129
130        assert!(migration.is_some());
131        let m = migration.unwrap();
132        assert!(m.name.contains("users"));
133        // Note: Actual CREATE TABLE SQL should come from manual migrations
134        assert!(m.sql.contains("Create table users"));
135    }
136}