Skip to main content

forge_runtime/migrations/
diff.rs

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