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 Some(first) = diff.entries.first() else {
57            return "empty".to_string();
58        };
59        match first.action {
60            super::diff::DiffAction::CreateTable => {
61                format!("create_{}", first.table_name)
62            }
63            super::diff::DiffAction::AddColumn => {
64                format!("add_column_to_{}", first.table_name)
65            }
66            super::diff::DiffAction::DropColumn => {
67                format!("remove_column_from_{}", first.table_name)
68            }
69            super::diff::DiffAction::DropTable => {
70                format!("drop_{}", first.table_name)
71            }
72            _ => "schema_update".to_string(),
73        }
74    }
75
76    /// Write migration to disk.
77    pub fn write_migration(&self, migration: &Migration) -> Result<(), GeneratorError> {
78        std::fs::create_dir_all(&self.output_dir).map_err(|e| GeneratorError::Io(e.to_string()))?;
79
80        let content = format!(
81            "-- Migration: {}\n-- Generated at: {}\n\n{}\n",
82            migration.name,
83            migration.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
84            migration.sql
85        );
86
87        std::fs::write(&migration.path, content).map_err(|e| GeneratorError::Io(e.to_string()))?;
88
89        Ok(())
90    }
91}
92
93/// A generated migration.
94#[derive(Debug, Clone)]
95pub struct Migration {
96    /// Version identifier (timestamp-based).
97    pub version: String,
98    /// Human-readable name.
99    pub name: String,
100    /// SQL content.
101    pub sql: String,
102    /// When the migration was created.
103    pub created_at: DateTime<Utc>,
104    /// Path to the migration file.
105    pub path: std::path::PathBuf,
106}
107
108/// Migration generator errors.
109#[derive(Debug, thiserror::Error)]
110pub enum GeneratorError {
111    #[error("IO error: {0}")]
112    Io(String),
113    #[error("Parse error: {0}")]
114    Parse(String),
115}
116
117#[cfg(test)]
118#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
119mod tests {
120    use super::*;
121    use forge_core::schema::RustType;
122    use forge_core::schema::{FieldDef, TableDef};
123
124    #[test]
125    fn test_generate_migration() {
126        let generator = MigrationGenerator::new("/tmp/migrations");
127
128        let mut table = TableDef::new("users", "User");
129        table.fields.push(FieldDef::new("id", RustType::Uuid));
130
131        let migration = generator.generate(&[table], &[]).unwrap();
132
133        assert!(migration.is_some());
134        let m = migration.unwrap();
135        assert!(m.name.contains("users"));
136        // Note: Actual CREATE TABLE SQL should come from manual migrations
137        assert!(m.sql.contains("Create table users"));
138    }
139}