Skip to main content

ferro_cli/templates/
entity.rs

1// Entity/model generation templates (used by `db:sync`)
2
3/// Convert PascalCase to snake_case
4pub(crate) fn to_snake_case(s: &str) -> String {
5    let mut result = String::new();
6    for (i, c) in s.chars().enumerate() {
7        if c.is_uppercase() && i > 0 {
8            result.push('_');
9        }
10        result.push(c.to_ascii_lowercase());
11    }
12    result
13}
14
15/// Column information from database schema
16pub struct ColumnInfo {
17    pub name: String,
18    pub col_type: String,
19    pub is_nullable: bool,
20    pub is_primary_key: bool,
21}
22
23/// Table information from database schema
24pub struct TableInfo {
25    pub name: String,
26    pub columns: Vec<ColumnInfo>,
27}
28
29/// Rust reserved keywords that need escaping with r# prefix
30const RUST_RESERVED_KEYWORDS: &[&str] = &[
31    "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for",
32    "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
33    "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where",
34    "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final", "macro",
35    "override", "priv", "typeof", "unsized", "virtual", "yield", "try",
36];
37
38/// Check if a name is a Rust reserved keyword
39fn is_reserved_keyword(name: &str) -> bool {
40    RUST_RESERVED_KEYWORDS.contains(&name)
41}
42
43/// Escape a column name if it's a reserved keyword
44fn escape_column_name(name: &str) -> String {
45    if is_reserved_keyword(name) {
46        format!("r#{name}")
47    } else {
48        name.to_string()
49    }
50}
51
52/// Generate auto-generated entity file (regenerated on every sync)
53pub fn entity_template(table_name: &str, columns: &[ColumnInfo]) -> String {
54    let _struct_name = to_pascal_case(&singularize(table_name));
55
56    // Generate column fields
57    let column_fields: Vec<String> = columns
58        .iter()
59        .map(|col| {
60            let rust_type = sql_type_to_rust_type(col);
61            let mut attrs = Vec::new();
62
63            if col.is_primary_key {
64                attrs.push("    #[sea_orm(primary_key)]".to_string());
65            }
66
67            // Handle reserved keywords
68            let field_name = escape_column_name(&col.name);
69            if is_reserved_keyword(&col.name) {
70                attrs.push(format!("    #[sea_orm(column_name = \"{}\")]", col.name));
71            }
72
73            let field = format!("    pub {field_name}: {rust_type},");
74            if attrs.is_empty() {
75                field
76            } else {
77                format!("{}\n{}", attrs.join("\n"), field)
78            }
79        })
80        .collect();
81
82    // Find primary key columns (reserved for future use)
83    let _pk_columns: Vec<&ColumnInfo> = columns.iter().filter(|c| c.is_primary_key).collect();
84
85    format!(
86        r#"// AUTO-GENERATED FILE - DO NOT EDIT
87// Generated by `ferro db:sync` - Changes will be overwritten
88// Add custom code to src/models/{table_name}.rs instead
89
90use ferro::FerroModel;
91use sea_orm::entity::prelude::*;
92use serde::Serialize;
93
94#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, FerroModel)]
95#[sea_orm(table_name = "{table_name}")]
96pub struct Model {{
97{columns}
98}}
99
100// Note: Relation enum is required here for DeriveEntityModel macro.
101// Define your actual relations in src/models/{table_name}.rs using the Related trait.
102#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
103pub enum Relation {{}}
104"#,
105        table_name = table_name,
106        columns = column_fields.join("\n"),
107    )
108}
109
110/// Generate user model file with Eloquent-like API (created only once, never overwritten)
111///
112/// The FerroModel derive macro (applied in entities/{table}.rs) generates:
113/// - query() - Start a query builder
114/// - create() - Return a builder for inserts
115/// - set_*() - Field setters on Model
116/// - update() - Save changes to database
117/// - delete() - Delete record
118/// - {Model}Builder struct with setters and insert()
119/// - ActiveModelBehavior, Model, and ModelMut trait implementations
120///
121/// This template only generates re-exports, type alias, and custom code sections.
122pub fn user_model_template(table_name: &str, struct_name: &str, columns: &[ColumnInfo]) -> String {
123    let pk_field = columns
124        .iter()
125        .find(|c| c.is_primary_key)
126        .map(|c| c.name.as_str())
127        .unwrap_or("id");
128
129    // Auto-implement Authenticatable for users table
130    let authenticatable_impl = if table_name == "users" {
131        format!(
132            r#"
133// ============================================================================
134// AUTHENTICATION
135// Auto-implemented Authenticatable trait for users table
136// ============================================================================
137
138impl ferro::auth::Authenticatable for Model {{
139    fn auth_identifier(&self) -> i64 {{
140        self.{pk_field} as i64
141    }}
142
143    fn as_any(&self) -> &dyn std::any::Any {{
144        self
145    }}
146}}
147"#
148        )
149    } else {
150        String::new()
151    };
152
153    format!(
154        r#"//! {struct_name} model
155//!
156//! This file contains custom implementations for the {struct_name} model.
157//! The base entity is auto-generated in src/models/entities/{table_name}.rs
158//!
159//! The FerroModel derive macro provides the Eloquent-like API:
160//! - {struct_name}::query() - Start a query builder
161//! - {struct_name}::create().set_field("value").insert() - Create records
162//! - model.set_field("value").update() - Update records
163//! - model.delete() - Delete records
164//!
165//! This file is NEVER overwritten by `ferro db:sync` - your custom code is safe here.
166
167// Re-export the auto-generated entity (includes FerroModel-generated code)
168pub use super::entities::{table_name}::*;
169
170/// Type alias for convenient access
171pub type {struct_name} = Model;
172
173// ============================================================================
174// CUSTOM METHODS
175// Add your custom query and mutation methods below
176// ============================================================================
177
178// Example custom finder:
179// impl Model {{
180//     pub async fn find_by_email(email: &str) -> Result<Option<Self>, ferro::FrameworkError> {{
181//         Self::query().filter(Column::Email.eq(email)).first().await
182//     }}
183// }}
184
185// ============================================================================
186// RELATIONS
187// Define relationships to other entities here
188// ============================================================================
189
190// Example: One-to-Many relation
191// impl Entity {{
192//     pub fn has_many_posts() -> RelationDef {{
193//         Entity::has_many(super::posts::Entity).into()
194//     }}
195// }}
196
197// Example: Belongs-To relation
198// impl Entity {{
199//     pub fn belongs_to_user() -> RelationDef {{
200//         Entity::belongs_to(super::users::Entity)
201//             .from(Column::UserId)
202//             .to(super::users::Column::Id)
203//             .into()
204//     }}
205// }}
206{authenticatable_impl}"#,
207    )
208}
209
210/// Generate entities/mod.rs (regenerated on every sync)
211pub fn entities_mod_template(tables: &[TableInfo]) -> String {
212    let mut content =
213        String::from("// AUTO-GENERATED FILE - DO NOT EDIT\n// Generated by `ferro db:sync`\n\n");
214
215    for table in tables {
216        content.push_str(&format!("pub mod {};\n", table.name));
217    }
218
219    content
220}
221
222// Helper functions for entity generation
223
224fn sql_type_to_rust_type(col: &ColumnInfo) -> String {
225    let col_type_upper = col.col_type.to_uppercase();
226    let base_type = if col_type_upper.contains("INT") {
227        if col_type_upper.contains("BIGINT") || col_type_upper.contains("INT8") {
228            "i64"
229        } else if col_type_upper.contains("SMALLINT") || col_type_upper.contains("INT2") {
230            "i16"
231        } else {
232            "i32"
233        }
234    } else if col_type_upper.contains("TEXT")
235        || col_type_upper.contains("VARCHAR")
236        || col_type_upper.contains("CHAR")
237        || col_type_upper.contains("CHARACTER")
238    {
239        "String"
240    } else if col_type_upper.contains("BOOL") {
241        "bool"
242    } else if col_type_upper.contains("REAL") || col_type_upper.contains("FLOAT4") {
243        "f32"
244    } else if col_type_upper.contains("DOUBLE") || col_type_upper.contains("FLOAT8") {
245        "f64"
246    } else if col_type_upper.contains("TIMESTAMP") || col_type_upper.contains("DATETIME") {
247        "DateTimeUtc"
248    } else if col_type_upper.contains("DATE") {
249        "Date"
250    } else if col_type_upper.contains("TIME") {
251        "Time"
252    } else if col_type_upper.contains("UUID") {
253        "Uuid"
254    } else if col_type_upper.contains("JSON") {
255        "Json"
256    } else if col_type_upper.contains("BYTEA") || col_type_upper.contains("BLOB") {
257        "Vec<u8>"
258    } else if col_type_upper.contains("DECIMAL") || col_type_upper.contains("NUMERIC") {
259        "Decimal"
260    } else {
261        "String" // fallback
262    };
263
264    if col.is_nullable {
265        format!("Option<{base_type}>")
266    } else {
267        base_type.to_string()
268    }
269}
270
271pub(crate) fn to_pascal_case(s: &str) -> String {
272    let mut result = String::new();
273    let mut capitalize_next = true;
274
275    for c in s.chars() {
276        if c == '_' || c == '-' || c == ' ' {
277            capitalize_next = true;
278        } else if capitalize_next {
279            result.push(c.to_uppercase().next().unwrap());
280            capitalize_next = false;
281        } else {
282            result.push(c);
283        }
284    }
285    result
286}
287
288fn singularize(word: &str) -> String {
289    if let Some(stem) = word.strip_suffix("ies") {
290        format!("{stem}y")
291    } else if let Some(stem) = word.strip_suffix("es") {
292        if word.ends_with("ses") || word.ends_with("xes") {
293            word.to_string()
294        } else {
295            stem.to_string()
296        }
297    } else if let Some(stem) = word.strip_suffix('s') {
298        if word.ends_with("ss") || word.ends_with("us") {
299            word.to_string()
300        } else {
301            stem.to_string()
302        }
303    } else {
304        word.to_string()
305    }
306}