Skip to main content

ferro_cli/commands/
db_sync.rs

1//! db:sync command - Run migrations and sync entity files from database schema
2
3use console::style;
4use sea_orm::{ConnectionTrait, Database, DbBackend, Statement};
5use std::env;
6use std::fs;
7use std::path::Path;
8use std::process::Command;
9
10use crate::templates;
11use crate::templates::{ColumnInfo, TableInfo};
12
13pub fn run(skip_migrations: bool, regenerate_models: bool) {
14    // Check we're in a Ferro project
15    if !Path::new("src/models").exists() && !Path::new("src/migrations").exists() {
16        eprintln!(
17            "{} Not in a Ferro project directory",
18            style("Error:").red().bold()
19        );
20        std::process::exit(1);
21    }
22
23    // Run migrations first (unless skipped)
24    if !skip_migrations {
25        run_migrations();
26    }
27
28    // Generate entities from database
29    generate_entities(regenerate_models);
30}
31
32fn run_migrations() {
33    if !Path::new("src/migrations").exists() {
34        println!(
35            "{} No migrations directory found, skipping migrations",
36            style("Info:").yellow()
37        );
38        return;
39    }
40
41    if !Path::new("src/bin/migrate.rs").exists() {
42        println!(
43            "{} Migration binary not found, skipping migrations",
44            style("Info:").yellow()
45        );
46        return;
47    }
48
49    println!("{} Running pending migrations...", style("→").cyan());
50
51    let status = Command::new("cargo")
52        .args(["run", "--bin", "migrate", "--", "up"])
53        .status()
54        .expect("Failed to execute cargo command");
55
56    if !status.success() {
57        eprintln!("{} Migration failed", style("Error:").red().bold());
58        std::process::exit(1);
59    }
60    println!("{} Migrations complete", style("✓").green());
61}
62
63fn generate_entities(regenerate_models: bool) {
64    // Load DATABASE_URL from .env
65    dotenvy::dotenv().ok();
66
67    let database_url = match env::var("DATABASE_URL") {
68        Ok(url) => url,
69        Err(_) => {
70            eprintln!(
71                "{} DATABASE_URL not set in .env",
72                style("Error:").red().bold()
73            );
74            std::process::exit(1);
75        }
76    };
77
78    println!("{} Discovering database schema...", style("→").cyan());
79
80    // Use tokio runtime for async schema discovery
81    let rt = tokio::runtime::Runtime::new().unwrap();
82    rt.block_on(async {
83        discover_and_generate(&database_url, regenerate_models).await;
84    });
85}
86
87async fn discover_and_generate(database_url: &str, regenerate_models: bool) {
88    let is_sqlite = database_url.starts_with("sqlite");
89
90    // Connect to database
91    let db = match Database::connect(database_url).await {
92        Ok(db) => db,
93        Err(e) => {
94            eprintln!(
95                "{} Failed to connect to database: {}",
96                style("Error:").red().bold(),
97                e
98            );
99            std::process::exit(1);
100        }
101    };
102
103    // Discover tables based on database type
104    let tables = if is_sqlite {
105        discover_sqlite_tables(&db).await
106    } else {
107        discover_postgres_tables(&db).await
108    };
109
110    // Filter out migration tables
111    let tables: Vec<_> = tables
112        .into_iter()
113        .filter(|t| t.name != "seaql_migrations" && !t.name.starts_with("_"))
114        .collect();
115
116    if tables.is_empty() {
117        println!("{} No tables found in database", style("Info:").yellow());
118        return;
119    }
120
121    println!(
122        "{} Found {} table(s): {}",
123        style("✓").green(),
124        tables.len(),
125        tables
126            .iter()
127            .map(|t| t.name.as_str())
128            .collect::<Vec<_>>()
129            .join(", ")
130    );
131
132    // Create models directory if it doesn't exist
133    let models_dir = Path::new("src/models");
134    if !models_dir.exists() {
135        fs::create_dir_all(models_dir).expect("Failed to create models directory");
136        println!("{} Created src/models directory", style("✓").green());
137    }
138
139    // Create entities directory
140    let entities_dir = models_dir.join("entities");
141    if !entities_dir.exists() {
142        fs::create_dir_all(&entities_dir).expect("Failed to create entities directory");
143        println!(
144            "{} Created src/models/entities directory",
145            style("✓").green()
146        );
147    }
148
149    // Generate entity files
150    for table in &tables {
151        generate_entity_file(table, &entities_dir);
152        if regenerate_models {
153            generate_user_file(table, models_dir);
154        } else {
155            generate_user_file_if_not_exists(table, models_dir);
156        }
157    }
158
159    // Update mod.rs files
160    update_entities_mod(&tables, &entities_dir);
161    update_models_mod(&tables, models_dir);
162
163    println!();
164    println!(
165        "{} Entity files generated successfully!",
166        style("✓").green().bold()
167    );
168    println!();
169    println!("Generated files:");
170    for table in &tables {
171        println!(
172            "  {} src/models/entities/{}.rs (auto-generated)",
173            style("•").dim(),
174            table.name
175        );
176        println!(
177            "  {} src/models/{}.rs (user customizations)",
178            style("•").dim(),
179            table.name
180        );
181    }
182}
183
184async fn discover_sqlite_tables(db: &sea_orm::DatabaseConnection) -> Vec<TableInfo> {
185    let mut tables = Vec::new();
186
187    // Get all table names
188    let table_names: Vec<String> = db
189        .query_all(Statement::from_string(
190            DbBackend::Sqlite,
191            "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
192        ))
193        .await
194        .unwrap_or_default()
195        .iter()
196        .filter_map(|row| row.try_get_by_index::<String>(0).ok())
197        .collect();
198
199    for table_name in table_names {
200        let columns = discover_sqlite_columns(db, &table_name).await;
201        tables.push(TableInfo {
202            name: table_name,
203            columns,
204        });
205    }
206
207    tables
208}
209
210async fn discover_sqlite_columns(
211    db: &sea_orm::DatabaseConnection,
212    table_name: &str,
213) -> Vec<ColumnInfo> {
214    let query = format!("PRAGMA table_info({table_name})");
215    let rows = db
216        .query_all(Statement::from_string(DbBackend::Sqlite, query))
217        .await
218        .unwrap_or_default();
219
220    rows.iter()
221        .filter_map(|row| {
222            let name: String = row.try_get_by_index(1).ok()?;
223            let col_type: String = row.try_get_by_index(2).ok()?;
224            let notnull: i32 = row.try_get_by_index(3).ok()?;
225            let pk: i32 = row.try_get_by_index(5).ok()?;
226
227            Some(ColumnInfo {
228                name,
229                col_type,
230                is_nullable: notnull == 0,
231                is_primary_key: pk > 0,
232            })
233        })
234        .collect()
235}
236
237async fn discover_postgres_tables(db: &sea_orm::DatabaseConnection) -> Vec<TableInfo> {
238    let mut tables = Vec::new();
239
240    // Get all table names from public schema
241    let table_names: Vec<String> = db
242        .query_all(Statement::from_string(
243            DbBackend::Postgres,
244            "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
245        ))
246        .await
247        .unwrap_or_default()
248        .iter()
249        .filter_map(|row| row.try_get_by_index::<String>(0).ok())
250        .collect();
251
252    for table_name in table_names {
253        let columns = discover_postgres_columns(db, &table_name).await;
254        tables.push(TableInfo {
255            name: table_name,
256            columns,
257        });
258    }
259
260    tables
261}
262
263async fn discover_postgres_columns(
264    db: &sea_orm::DatabaseConnection,
265    table_name: &str,
266) -> Vec<ColumnInfo> {
267    let query = format!(
268        r#"
269        SELECT
270            c.column_name,
271            c.data_type,
272            c.is_nullable,
273            CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_pk
274        FROM information_schema.columns c
275        LEFT JOIN (
276            SELECT ku.column_name
277            FROM information_schema.table_constraints tc
278            JOIN information_schema.key_column_usage ku
279                ON tc.constraint_name = ku.constraint_name
280            WHERE tc.constraint_type = 'PRIMARY KEY'
281                AND tc.table_name = '{table_name}'
282        ) pk ON c.column_name = pk.column_name
283        WHERE c.table_name = '{table_name}'
284        ORDER BY c.ordinal_position
285        "#
286    );
287
288    let rows = db
289        .query_all(Statement::from_string(DbBackend::Postgres, query))
290        .await
291        .unwrap_or_default();
292
293    rows.iter()
294        .filter_map(|row| {
295            let name: String = row.try_get_by_index(0).ok()?;
296            let col_type: String = row.try_get_by_index(1).ok()?;
297            let is_nullable_str: String = row.try_get_by_index(2).ok()?;
298            let is_pk: bool = row.try_get_by_index(3).ok()?;
299
300            Some(ColumnInfo {
301                name,
302                col_type,
303                is_nullable: is_nullable_str == "YES",
304                is_primary_key: is_pk,
305            })
306        })
307        .collect()
308}
309
310fn generate_entity_file(table: &TableInfo, entities_dir: &Path) {
311    let entity_file = entities_dir.join(format!("{}.rs", table.name));
312    let content = templates::entity_template(&table.name, &table.columns);
313
314    fs::write(&entity_file, content).expect("Failed to write entity file");
315    println!(
316        "{} Generated src/models/entities/{}.rs",
317        style("✓").green(),
318        table.name
319    );
320}
321
322fn generate_user_file_if_not_exists(table: &TableInfo, models_dir: &Path) {
323    let user_file = models_dir.join(format!("{}.rs", table.name));
324
325    // Only create if it doesn't exist
326    if user_file.exists() {
327        println!(
328            "{} Skipped src/models/{}.rs (already exists)",
329            style("•").dim(),
330            table.name
331        );
332        return;
333    }
334
335    let struct_name = to_pascal_case(&singularize(&table.name));
336    let content = templates::user_model_template(&table.name, &struct_name, &table.columns);
337
338    fs::write(&user_file, content).expect("Failed to write user model file");
339    println!(
340        "{} Created src/models/{}.rs",
341        style("✓").green(),
342        table.name
343    );
344}
345
346fn generate_user_file(table: &TableInfo, models_dir: &Path) {
347    let user_file = models_dir.join(format!("{}.rs", table.name));
348    let struct_name = to_pascal_case(&singularize(&table.name));
349    let content = templates::user_model_template(&table.name, &struct_name, &table.columns);
350
351    fs::write(&user_file, content).expect("Failed to write user model file");
352    println!(
353        "{} Regenerated src/models/{}.rs",
354        style("✓").green(),
355        table.name
356    );
357}
358
359fn update_entities_mod(tables: &[TableInfo], entities_dir: &Path) {
360    let mod_file = entities_dir.join("mod.rs");
361    let content = templates::entities_mod_template(tables);
362
363    fs::write(&mod_file, content).expect("Failed to write entities/mod.rs");
364    println!("{} Updated src/models/entities/mod.rs", style("✓").green());
365}
366
367fn update_models_mod(tables: &[TableInfo], models_dir: &Path) {
368    let mod_file = models_dir.join("mod.rs");
369
370    // Read existing content or create new
371    let existing_content = if mod_file.exists() {
372        fs::read_to_string(&mod_file).unwrap_or_default()
373    } else {
374        "//! Application models\n\n".to_string()
375    };
376
377    let mut lines: Vec<String> = existing_content.lines().map(String::from).collect();
378
379    // Check if entities module is declared
380    let has_entities_mod = lines.iter().any(|l| {
381        let trimmed = l.trim();
382        trimmed == "pub mod entities;" || trimmed == "mod entities;"
383    });
384
385    // Find insertion point (after doc comments)
386    let mut insert_idx = 0;
387    for (i, line) in lines.iter().enumerate() {
388        if line.starts_with("//!") || line.is_empty() {
389            insert_idx = i + 1;
390        } else {
391            break;
392        }
393    }
394
395    // Add entities module if not present
396    if !has_entities_mod {
397        lines.insert(insert_idx, "pub mod entities;".to_string());
398        insert_idx += 1;
399    }
400
401    // Add missing table modules
402    for table in tables {
403        let mod_decl = format!("pub mod {};", table.name);
404        let alt_mod_decl = format!("mod {};", table.name);
405
406        if !lines
407            .iter()
408            .any(|l| l.trim() == mod_decl || l.trim() == alt_mod_decl)
409        {
410            // Find last pub mod declaration
411            let mut last_mod_idx = insert_idx;
412            for (i, line) in lines.iter().enumerate() {
413                if line.trim().starts_with("pub mod ") || line.trim().starts_with("mod ") {
414                    last_mod_idx = i + 1;
415                }
416            }
417            lines.insert(last_mod_idx, mod_decl);
418        }
419    }
420
421    let content = lines.join("\n");
422    fs::write(&mod_file, content).expect("Failed to write models/mod.rs");
423    println!("{} Updated src/models/mod.rs", style("✓").green());
424}
425
426fn to_pascal_case(s: &str) -> String {
427    let mut result = String::new();
428    let mut capitalize_next = true;
429
430    for c in s.chars() {
431        if c == '_' || c == '-' || c == ' ' {
432            capitalize_next = true;
433        } else if capitalize_next {
434            result.push(c.to_uppercase().next().unwrap());
435            capitalize_next = false;
436        } else {
437            result.push(c);
438        }
439    }
440    result
441}
442
443fn singularize(word: &str) -> String {
444    // Basic singularization
445    if let Some(stem) = word.strip_suffix("ies") {
446        format!("{stem}y")
447    } else if let Some(stem) = word.strip_suffix("es") {
448        if word.ends_with("ses") || word.ends_with("xes") {
449            word.to_string()
450        } else {
451            stem.to_string()
452        }
453    } else if let Some(stem) = word.strip_suffix('s') {
454        if word.ends_with("ss") || word.ends_with("us") {
455            word.to_string()
456        } else {
457            stem.to_string()
458        }
459    } else {
460        word.to_string()
461    }
462}