Skip to main content

backbone_orm/
migrations.rs

1//! Database migration system
2
3use anyhow::Result;
4use sqlx::{PgPool, Row};
5use std::fs;
6use std::path::Path;
7use colored::*;
8use chrono::{DateTime, Utc};
9
10/// Migration manager
11pub struct MigrationManager {
12    pool: PgPool,
13}
14
15impl MigrationManager {
16    pub fn new(pool: PgPool) -> Self {
17        Self { pool }
18    }
19
20    /// Run all pending migrations
21    pub async fn migrate(&self) -> Result<()> {
22        println!("🔄 Running database migrations...");
23
24        // Create migrations table if it doesn't exist
25        self.create_migrations_table().await?;
26
27        // Get all migration files
28        let migrations = self.load_migration_files()?;
29
30        // Get applied migrations from database
31        let applied_migrations = self.get_applied_migrations().await?;
32
33        // Run pending migrations
34        for migration in migrations {
35            if !applied_migrations.contains(&migration.name) {
36                println!("  â†ŗ Applying migration: {}", migration.name.bright_green());
37                self.apply_migration(&migration).await?;
38            } else {
39                println!("  ✓ Skipping already applied: {}", migration.name);
40            }
41        }
42
43        println!("✅ Migrations completed successfully");
44        Ok(())
45    }
46
47    /// Rollback the last migration
48    pub async fn rollback(&self) -> Result<()> {
49        println!("🔄 Rolling back last migration...");
50
51        // Create migrations table if it doesn't exist
52        self.create_migrations_table().await?;
53
54        // Get the last applied migration
55        let last_migration = self.get_last_applied_migration().await?;
56
57        match last_migration {
58            Some(migration_name) => {
59                println!("  â†ŗ Rolling back migration: {}", migration_name.bright_yellow());
60
61                // Find and execute the down migration
62                let down_migration = self.find_down_migration(&migration_name)?;
63
64                if let Some(down_content) = down_migration {
65                    self.rollback_migration(&migration_name, &down_content).await?;
66                    println!("  ✓ Successfully rolled back: {}", migration_name.bright_green());
67                } else {
68                    println!("  âš ī¸  No down migration found for: {}", migration_name.yellow());
69                }
70            }
71            None => {
72                println!("  â„šī¸  No migrations to rollback");
73            }
74        }
75
76        println!("✅ Rollback completed");
77        Ok(())
78    }
79
80    /// Rollback a specific number of migrations
81    pub async fn rollback_n(&self, count: usize) -> Result<()> {
82        println!("🔄 Rolling back {} migrations...", count);
83
84        for i in 0..count {
85            let last_migration = self.get_last_applied_migration().await?;
86
87            match last_migration {
88                Some(migration_name) => {
89                    println!("  â†ŗ Rolling back {}/1: {}", i + 1, migration_name.bright_yellow());
90
91                    let down_migration = self.find_down_migration(&migration_name)?;
92
93                    if let Some(down_content) = down_migration {
94                        self.rollback_migration(&migration_name, &down_content).await?;
95                        println!("  ✓ Successfully rolled back: {}", migration_name.bright_green());
96                    } else {
97                        println!("  âš ī¸  No down migration found for: {}", migration_name.yellow());
98                        break;
99                    }
100                }
101                None => {
102                    println!("  â„šī¸  No more migrations to rollback");
103                    break;
104                }
105            }
106        }
107
108        println!("✅ Rollback of {} migrations completed", count);
109        Ok(())
110    }
111
112    /// Get migration history
113    pub async fn history(&self) -> Result<Vec<MigrationRecord>> {
114        // Create migrations table if it doesn't exist
115        self.create_migrations_table().await?;
116
117        let rows = sqlx::query("SELECT id, name, applied_at FROM schema_migrations ORDER BY applied_at DESC")
118            .fetch_all(&self.pool)
119            .await?;
120
121        let records: Result<Vec<MigrationRecord>, sqlx::Error> = rows.into_iter()
122            .map(|row| {
123                Ok(MigrationRecord {
124                    id: row.get("id"),
125                    name: row.get("name"),
126                    applied_at: row.get("applied_at"),
127                })
128            })
129            .collect();
130
131        match records {
132            Ok(history) => {
133                if history.is_empty() {
134                    println!("  â„šī¸  No migrations have been applied yet");
135                } else {
136                    println!("📋 Migration History:");
137                    for (i, record) in history.iter().enumerate() {
138                        let status = if i == 0 {
139                            "🔹".to_string() // Current (last) migration
140                        } else {
141                            "  ".to_string()
142                        };
143                        println!("  {} {} - Applied at {}", status, record.name.bright_cyan(), record.applied_at.format("%Y-%m-%d %H:%M:%S"));
144                    }
145                }
146                Ok(history)
147            }
148            Err(e) => Err(anyhow::anyhow!("Failed to load migration history: {}", e))
149        }
150    }
151
152    /// Validate migration status
153    pub async fn status(&self) -> Result<MigrationStatus> {
154        // Create migrations table if it doesn't exist
155        self.create_migrations_table().await?;
156
157        // Get migration files
158        let migration_files = self.load_migration_files()?;
159
160        // Get applied migrations
161        let applied_migrations = self.get_applied_migrations().await?;
162
163        // Determine pending and applied migrations
164        let mut pending_migrations = Vec::new();
165        let mut applied_migration_details = Vec::new();
166
167        for migration_file in migration_files {
168            if applied_migrations.contains(&migration_file.name) {
169                applied_migration_details.push(migration_file.name);
170            } else {
171                pending_migrations.push(migration_file.name);
172            }
173        }
174
175        let status = MigrationStatus {
176            total_migrations: applied_migration_details.len() + pending_migrations.len(),
177            applied_migrations: applied_migration_details,
178            pending_migrations,
179            last_migration: applied_migrations.last().cloned(),
180        };
181
182        // Print status
183        println!("📊 Migration Status:");
184        println!("  Total migrations: {}", status.total_migrations);
185        println!("  Applied migrations: {}", status.applied_migrations.len());
186        println!("  Pending migrations: {}", status.pending_migrations.len());
187
188        if !status.pending_migrations.is_empty() {
189            println!("  Pending:");
190            for migration in &status.pending_migrations {
191                println!("    - {}", migration.yellow());
192            }
193        }
194
195        Ok(status)
196    }
197
198    /// Get the last applied migration
199    async fn get_last_applied_migration(&self) -> Result<Option<String>> {
200        let row = sqlx::query("SELECT name FROM schema_migrations ORDER BY applied_at DESC LIMIT 1")
201            .fetch_optional(&self.pool)
202            .await?;
203
204        Ok(row.map(|r| r.get("name")))
205    }
206
207    /// Find the corresponding down migration file.
208    ///
209    /// Migrations live as paired siblings: `<name>.up.sql` and
210    /// `<name>.down.sql`. The migration "name" stored in the
211    /// `schema_migrations` table is the part before `.up.sql`, so finding
212    /// the rollback is a direct path lookup.
213    fn find_down_migration(&self, migration_name: &str) -> Result<Option<String>> {
214        let path = Path::new("migrations").join(format!("{}.down.sql", migration_name));
215        if path.exists() {
216            Ok(Some(fs::read_to_string(&path)?))
217        } else {
218            Ok(None)
219        }
220    }
221
222    /// Rollback a specific migration in a single transaction.
223    ///
224    /// Down migrations are multi-statement (drop indexes, drop table,
225    /// drop functions, drop types) so we use `sqlx::raw_sql` rather than
226    /// `sqlx::query` — the latter goes through the extended protocol and
227    /// rejects compound statements.
228    async fn rollback_migration(&self, migration_name: &str, down_content: &str) -> Result<()> {
229        let mut tx = self.pool.begin().await?;
230
231        sqlx::raw_sql(down_content)
232            .execute(&mut *tx)
233            .await?;
234
235        sqlx::query("DELETE FROM schema_migrations WHERE name = $1")
236            .bind(migration_name)
237            .execute(&mut *tx)
238            .await?;
239
240        tx.commit().await?;
241        Ok(())
242    }
243
244    /// Create a new paired up/down migration: `<ts>_<name>.up.sql` and
245    /// `<ts>_<name>.down.sql` under `migrations/`. Timestamp is the
246    /// `YYYYMMDDHHMMSS` form so it sorts cleanly next to generator-emitted
247    /// migrations.
248    pub async fn create_migration(&self, name: &str) -> Result<()> {
249        println!("📝 Creating new migration: {}", name.bright_cyan());
250
251        let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
252        let stem = format!("{}_{}", timestamp, name);
253        let migrations_dir = "migrations";
254        fs::create_dir_all(migrations_dir)?;
255
256        let up_content = format!(
257            r#"-- Migration: {name}
258-- Created: {timestamp}
259
260-- Add your migration SQL here
261-- Example:
262-- CREATE TABLE example_table (
263--     id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
264--     created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
265--     updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
266-- );
267"#,
268            name = name,
269            timestamp = timestamp
270        );
271        let up_path = Path::new(migrations_dir).join(format!("{}.up.sql", stem));
272        fs::write(&up_path, up_content)?;
273
274        let down_content = format!(
275            r#"-- Down Migration: {name}
276-- Created: {timestamp}
277
278-- Add your rollback SQL here
279-- Example:
280-- DROP TABLE IF EXISTS example_table CASCADE;
281"#,
282            name = name,
283            timestamp = timestamp
284        );
285        let down_path = Path::new(migrations_dir).join(format!("{}.down.sql", stem));
286        fs::write(&down_path, down_content)?;
287
288        println!("  ✓ Created: {}", up_path.display().to_string().bright_green());
289        println!("  ✓ Created: {}", down_path.display().to_string().bright_green());
290        Ok(())
291    }
292
293    /// Create migrations table to track applied migrations
294    async fn create_migrations_table(&self) -> Result<()> {
295        sqlx::query(
296            r#"
297            CREATE TABLE IF NOT EXISTS schema_migrations (
298                id SERIAL PRIMARY KEY,
299                name VARCHAR(255) NOT NULL UNIQUE,
300                applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
301            )
302            "#
303        )
304        .execute(&self.pool)
305        .await?;
306
307        Ok(())
308    }
309
310    /// Load up-migration files from `migrations/`, sorted by name (which
311    /// for our convention also sorts chronologically because filenames are
312    /// timestamp-prefixed). The "name" returned strips the `.up.sql`
313    /// suffix so it matches what gets recorded in `schema_migrations`.
314    fn load_migration_files(&self) -> Result<Vec<MigrationFile>> {
315        let migrations_dir = Path::new("migrations");
316        let mut migrations = Vec::new();
317
318        if migrations_dir.exists() {
319            for entry in fs::read_dir(migrations_dir)? {
320                let entry = entry?;
321                let path = entry.path();
322                let Some(file_name) = path.file_name().and_then(|f| f.to_str()) else {
323                    continue;
324                };
325                let Some(name) = file_name.strip_suffix(".up.sql") else {
326                    continue;
327                };
328                let content = fs::read_to_string(&path)?;
329                migrations.push(MigrationFile {
330                    name: name.to_string(),
331                    content,
332                });
333            }
334        }
335
336        migrations.sort_by(|a, b| a.name.cmp(&b.name));
337        Ok(migrations)
338    }
339
340    /// Get list of applied migrations from database
341    async fn get_applied_migrations(&self) -> Result<Vec<String>> {
342        let rows = sqlx::query("SELECT name FROM schema_migrations ORDER BY name")
343            .fetch_all(&self.pool)
344            .await?;
345
346        let migrations: Vec<String> = rows
347            .into_iter()
348            .map(|row| row.get::<String, _>("name"))
349            .collect();
350
351        Ok(migrations)
352    }
353
354    /// Apply a single migration in a single transaction.
355    ///
356    /// Generated `.up.sql` files contain multiple statements (CREATE
357    /// TABLE + CREATE INDEX + CREATE FUNCTION + ALTER TABLE â€Ļ). Use
358    /// `sqlx::raw_sql` so the simple-query protocol handles the compound
359    /// SQL — `sqlx::query` only accepts a single statement.
360    async fn apply_migration(&self, migration: &MigrationFile) -> Result<()> {
361        let mut tx = self.pool.begin().await?;
362
363        sqlx::raw_sql(&migration.content)
364            .execute(&mut *tx)
365            .await?;
366
367        sqlx::query("INSERT INTO schema_migrations (name) VALUES ($1)")
368            .bind(&migration.name)
369            .execute(&mut *tx)
370            .await?;
371
372        tx.commit().await?;
373        Ok(())
374    }
375}
376
377/// Migration file representation
378struct MigrationFile {
379    name: String,
380    content: String,
381}
382
383/// Migration trait
384pub trait Migration {
385    fn name(&self) -> &str;
386    fn up(&self) -> &str;
387    fn down(&self) -> &str;
388}
389
390/// Migration record from database
391#[derive(Debug, Clone)]
392pub struct MigrationRecord {
393    pub id: i32,
394    pub name: String,
395    pub applied_at: DateTime<Utc>,
396}
397
398/// Migration status information
399#[derive(Debug, Clone)]
400pub struct MigrationStatus {
401    pub total_migrations: usize,
402    pub applied_migrations: Vec<String>,
403    pub pending_migrations: Vec<String>,
404    pub last_migration: Option<String>,
405}