ferro_cli/templates/
entity.rs1pub(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
15pub struct ColumnInfo {
17 pub name: String,
18 pub col_type: String,
19 pub is_nullable: bool,
20 pub is_primary_key: bool,
21}
22
23pub struct TableInfo {
25 pub name: String,
26 pub columns: Vec<ColumnInfo>,
27}
28
29const 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
38fn is_reserved_keyword(name: &str) -> bool {
40 RUST_RESERVED_KEYWORDS.contains(&name)
41}
42
43fn 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
52pub fn entity_template(table_name: &str, columns: &[ColumnInfo]) -> String {
54 let _struct_name = to_pascal_case(&singularize(table_name));
55
56 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 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 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
110pub 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 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
210pub 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
222fn 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" };
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}