forge_runtime/migrations/
diff.rs

1use forge_core::schema::{FieldDef, TableDef};
2
3/// Represents the difference between two schemas.
4#[derive(Debug, Clone)]
5pub struct SchemaDiff {
6    /// Changes to be applied.
7    pub entries: Vec<DiffEntry>,
8}
9
10impl SchemaDiff {
11    /// Create an empty diff.
12    pub fn new() -> Self {
13        Self {
14            entries: Vec::new(),
15        }
16    }
17
18    /// Compare a Rust schema to a database schema.
19    pub fn from_comparison(rust_tables: &[TableDef], db_tables: &[DatabaseTable]) -> Self {
20        let mut entries = Vec::new();
21
22        // Find tables to add
23        for rust_table in rust_tables {
24            let db_table = db_tables.iter().find(|t| t.name == rust_table.name);
25
26            match db_table {
27                None => {
28                    // Table doesn't exist, create it
29                    entries.push(DiffEntry {
30                        action: DiffAction::CreateTable,
31                        table_name: rust_table.name.clone(),
32                        details: format!("Create table {}", rust_table.name),
33                        sql: rust_table.to_create_table_sql(),
34                    });
35                }
36                Some(db) => {
37                    // Compare columns
38                    for rust_field in &rust_table.fields {
39                        let db_column =
40                            db.columns.iter().find(|c| c.name == rust_field.column_name);
41
42                        match db_column {
43                            None => {
44                                // Column doesn't exist, add it
45                                entries.push(DiffEntry {
46                                    action: DiffAction::AddColumn,
47                                    table_name: rust_table.name.clone(),
48                                    details: format!("Add column {}", rust_field.column_name),
49                                    sql: Self::add_column_sql(&rust_table.name, rust_field),
50                                });
51                            }
52                            Some(db_col) => {
53                                // Check if column type changed
54                                let rust_type = rust_field.sql_type.to_sql();
55                                if db_col.data_type != rust_type {
56                                    entries.push(DiffEntry {
57                                        action: DiffAction::AlterColumn,
58                                        table_name: rust_table.name.clone(),
59                                        details: format!(
60                                            "Change column {} type from {} to {}",
61                                            rust_field.column_name, db_col.data_type, rust_type
62                                        ),
63                                        sql: format!(
64                                            "ALTER TABLE {} ALTER COLUMN {} TYPE {};",
65                                            rust_table.name, rust_field.column_name, rust_type
66                                        ),
67                                    });
68                                }
69                            }
70                        }
71                    }
72
73                    // Find columns to drop (exist in DB but not in Rust)
74                    for db_col in &db.columns {
75                        let exists_in_rust = rust_table
76                            .fields
77                            .iter()
78                            .any(|f| f.column_name == db_col.name);
79
80                        if !exists_in_rust {
81                            entries.push(DiffEntry {
82                                action: DiffAction::DropColumn,
83                                table_name: rust_table.name.clone(),
84                                details: format!("Drop column {}", db_col.name),
85                                sql: format!(
86                                    "ALTER TABLE {} DROP COLUMN {};",
87                                    rust_table.name, db_col.name
88                                ),
89                            });
90                        }
91                    }
92                }
93            }
94        }
95
96        // Find tables to drop (exist in DB but not in Rust)
97        for db_table in db_tables {
98            let exists_in_rust = rust_tables.iter().any(|t| t.name == db_table.name);
99
100            if !exists_in_rust && !db_table.name.starts_with("forge_") {
101                entries.push(DiffEntry {
102                    action: DiffAction::DropTable,
103                    table_name: db_table.name.clone(),
104                    details: format!("Drop table {}", db_table.name),
105                    sql: format!("DROP TABLE {};", db_table.name),
106                });
107            }
108        }
109
110        Self { entries }
111    }
112
113    fn add_column_sql(table_name: &str, field: &FieldDef) -> String {
114        let mut sql = format!(
115            "ALTER TABLE {} ADD COLUMN {} {}",
116            table_name,
117            field.column_name,
118            field.sql_type.to_sql()
119        );
120
121        if !field.nullable {
122            if let Some(ref default) = field.default {
123                sql.push_str(&format!(" NOT NULL DEFAULT {}", default));
124            } else {
125                // For non-nullable columns without default, we need a default value
126                let default_val =
127                    match field.sql_type {
128                        forge_core::schema::SqlType::Varchar(_)
129                        | forge_core::schema::SqlType::Text => "''",
130                        forge_core::schema::SqlType::Integer
131                        | forge_core::schema::SqlType::BigInt => "0",
132                        forge_core::schema::SqlType::Boolean => "false",
133                        forge_core::schema::SqlType::Timestamptz => "NOW()",
134                        _ => "NULL",
135                    };
136                sql.push_str(&format!(" NOT NULL DEFAULT {}", default_val));
137            }
138        }
139
140        sql.push(';');
141        sql
142    }
143
144    /// Check if there are any changes.
145    pub fn is_empty(&self) -> bool {
146        self.entries.is_empty()
147    }
148
149    /// Get all SQL statements.
150    pub fn to_sql(&self) -> Vec<String> {
151        self.entries.iter().map(|e| e.sql.clone()).collect()
152    }
153}
154
155impl Default for SchemaDiff {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161/// A single diff entry.
162#[derive(Debug, Clone)]
163pub struct DiffEntry {
164    /// Type of action.
165    pub action: DiffAction,
166    /// Affected table name.
167    pub table_name: String,
168    /// Human-readable description.
169    pub details: String,
170    /// SQL to apply.
171    pub sql: String,
172}
173
174/// Type of schema change.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum DiffAction {
177    CreateTable,
178    DropTable,
179    AddColumn,
180    DropColumn,
181    AlterColumn,
182    AddIndex,
183    DropIndex,
184    CreateEnum,
185    AlterEnum,
186}
187
188/// Representation of a database table (from introspection).
189#[derive(Debug, Clone)]
190pub struct DatabaseTable {
191    pub name: String,
192    pub columns: Vec<DatabaseColumn>,
193}
194
195/// Representation of a database column (from introspection).
196#[derive(Debug, Clone)]
197pub struct DatabaseColumn {
198    pub name: String,
199    pub data_type: String,
200    pub nullable: bool,
201    pub default: Option<String>,
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use forge_core::schema::FieldAttribute;
208    use forge_core::schema::RustType;
209    use forge_core::schema::{FieldDef, TableDef};
210
211    #[test]
212    fn test_empty_diff() {
213        let diff = SchemaDiff::new();
214        assert!(diff.is_empty());
215    }
216
217    #[test]
218    fn test_create_table_diff() {
219        let mut table = TableDef::new("users", "User");
220        let mut id_field = FieldDef::new("id", RustType::Uuid);
221        id_field.attributes.push(FieldAttribute::Id);
222        table.fields.push(id_field);
223
224        let diff = SchemaDiff::from_comparison(&[table], &[]);
225
226        assert_eq!(diff.entries.len(), 1);
227        assert_eq!(diff.entries[0].action, DiffAction::CreateTable);
228    }
229
230    #[test]
231    fn test_add_column_diff() {
232        let mut rust_table = TableDef::new("users", "User");
233        let id_field = FieldDef::new("id", RustType::Uuid);
234        let email_field = FieldDef::new("email", RustType::String);
235        rust_table.fields.push(id_field);
236        rust_table.fields.push(email_field);
237
238        let db_table = DatabaseTable {
239            name: "users".to_string(),
240            columns: vec![DatabaseColumn {
241                name: "id".to_string(),
242                data_type: "UUID".to_string(),
243                nullable: false,
244                default: None,
245            }],
246        };
247
248        let diff = SchemaDiff::from_comparison(&[rust_table], &[db_table]);
249
250        assert_eq!(diff.entries.len(), 1);
251        assert_eq!(diff.entries[0].action, DiffAction::AddColumn);
252        assert!(diff.entries[0].details.contains("email"));
253    }
254}