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