pub(crate) fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
result.push(c.to_ascii_lowercase());
}
result
}
pub struct ColumnInfo {
pub name: String,
pub col_type: String,
pub is_nullable: bool,
pub is_primary_key: bool,
}
pub struct TableInfo {
pub name: String,
pub columns: Vec<ColumnInfo>,
}
const RUST_RESERVED_KEYWORDS: &[&str] = &[
"as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for",
"if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
"self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where",
"while", "async", "await", "dyn", "abstract", "become", "box", "do", "final", "macro",
"override", "priv", "typeof", "unsized", "virtual", "yield", "try",
];
fn is_reserved_keyword(name: &str) -> bool {
RUST_RESERVED_KEYWORDS.contains(&name)
}
fn escape_column_name(name: &str) -> String {
if is_reserved_keyword(name) {
format!("r#{name}")
} else {
name.to_string()
}
}
pub fn entity_template(table_name: &str, columns: &[ColumnInfo]) -> String {
let _struct_name = to_pascal_case(&singularize(table_name));
let column_fields: Vec<String> = columns
.iter()
.map(|col| {
let rust_type = sql_type_to_rust_type(col);
let mut attrs = Vec::new();
if col.is_primary_key {
attrs.push(" #[sea_orm(primary_key)]".to_string());
}
let field_name = escape_column_name(&col.name);
if is_reserved_keyword(&col.name) {
attrs.push(format!(" #[sea_orm(column_name = \"{}\")]", col.name));
}
let field = format!(" pub {field_name}: {rust_type},");
if attrs.is_empty() {
field
} else {
format!("{}\n{}", attrs.join("\n"), field)
}
})
.collect();
let _pk_columns: Vec<&ColumnInfo> = columns.iter().filter(|c| c.is_primary_key).collect();
format!(
r#"// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by `ferro db:sync` - Changes will be overwritten
// Add custom code to src/models/{table_name}.rs instead
use ferro::FerroModel;
use sea_orm::entity::prelude::*;
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, FerroModel)]
#[sea_orm(table_name = "{table_name}")]
pub struct Model {{
{columns}
}}
// Note: Relation enum is required here for DeriveEntityModel macro.
// Define your actual relations in src/models/{table_name}.rs using the Related trait.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {{}}
"#,
table_name = table_name,
columns = column_fields.join("\n"),
)
}
pub fn user_model_template(table_name: &str, struct_name: &str, columns: &[ColumnInfo]) -> String {
let pk_field = columns
.iter()
.find(|c| c.is_primary_key)
.map(|c| c.name.as_str())
.unwrap_or("id");
let authenticatable_impl = if table_name == "users" {
format!(
r#"
// ============================================================================
// AUTHENTICATION
// Auto-implemented Authenticatable trait for users table
// ============================================================================
impl ferro::auth::Authenticatable for Model {{
fn auth_identifier(&self) -> i64 {{
self.{pk_field} as i64
}}
fn as_any(&self) -> &dyn std::any::Any {{
self
}}
}}
"#
)
} else {
String::new()
};
format!(
r#"//! {struct_name} model
//!
//! This file contains custom implementations for the {struct_name} model.
//! The base entity is auto-generated in src/models/entities/{table_name}.rs
//!
//! The FerroModel derive macro provides the Eloquent-like API:
//! - {struct_name}::query() - Start a query builder
//! - {struct_name}::create().set_field("value").insert() - Create records
//! - model.set_field("value").update() - Update records
//! - model.delete() - Delete records
//!
//! This file is NEVER overwritten by `ferro db:sync` - your custom code is safe here.
// Re-export the auto-generated entity (includes FerroModel-generated code)
pub use super::entities::{table_name}::*;
/// Type alias for convenient access
pub type {struct_name} = Model;
// ============================================================================
// CUSTOM METHODS
// Add your custom query and mutation methods below
// ============================================================================
// Example custom finder:
// impl Model {{
// pub async fn find_by_email(email: &str) -> Result<Option<Self>, ferro::FrameworkError> {{
// Self::query().filter(Column::Email.eq(email)).first().await
// }}
// }}
// ============================================================================
// RELATIONS
// Define relationships to other entities here
// ============================================================================
// Example: One-to-Many relation
// impl Entity {{
// pub fn has_many_posts() -> RelationDef {{
// Entity::has_many(super::posts::Entity).into()
// }}
// }}
// Example: Belongs-To relation
// impl Entity {{
// pub fn belongs_to_user() -> RelationDef {{
// Entity::belongs_to(super::users::Entity)
// .from(Column::UserId)
// .to(super::users::Column::Id)
// .into()
// }}
// }}
{authenticatable_impl}"#,
)
}
pub fn entities_mod_template(tables: &[TableInfo]) -> String {
let mut content =
String::from("// AUTO-GENERATED FILE - DO NOT EDIT\n// Generated by `ferro db:sync`\n\n");
for table in tables {
content.push_str(&format!("pub mod {};\n", table.name));
}
content
}
fn sql_type_to_rust_type(col: &ColumnInfo) -> String {
let col_type_upper = col.col_type.to_uppercase();
let base_type = if col_type_upper.contains("INT") {
if col_type_upper.contains("BIGINT") || col_type_upper.contains("INT8") {
"i64"
} else if col_type_upper.contains("SMALLINT") || col_type_upper.contains("INT2") {
"i16"
} else {
"i32"
}
} else if col_type_upper.contains("TEXT")
|| col_type_upper.contains("VARCHAR")
|| col_type_upper.contains("CHAR")
|| col_type_upper.contains("CHARACTER")
{
"String"
} else if col_type_upper.contains("BOOL") {
"bool"
} else if col_type_upper.contains("REAL") || col_type_upper.contains("FLOAT4") {
"f32"
} else if col_type_upper.contains("DOUBLE") || col_type_upper.contains("FLOAT8") {
"f64"
} else if col_type_upper.contains("TIMESTAMP") || col_type_upper.contains("DATETIME") {
"DateTimeUtc"
} else if col_type_upper.contains("DATE") {
"Date"
} else if col_type_upper.contains("TIME") {
"Time"
} else if col_type_upper.contains("UUID") {
"Uuid"
} else if col_type_upper.contains("JSON") {
"Json"
} else if col_type_upper.contains("BYTEA") || col_type_upper.contains("BLOB") {
"Vec<u8>"
} else if col_type_upper.contains("DECIMAL") || col_type_upper.contains("NUMERIC") {
"Decimal"
} else {
"String" };
if col.is_nullable {
format!("Option<{base_type}>")
} else {
base_type.to_string()
}
}
pub(crate) fn to_pascal_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = true;
for c in s.chars() {
if c == '_' || c == '-' || c == ' ' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_uppercase().next().unwrap());
capitalize_next = false;
} else {
result.push(c);
}
}
result
}
fn singularize(word: &str) -> String {
if let Some(stem) = word.strip_suffix("ies") {
format!("{stem}y")
} else if let Some(stem) = word.strip_suffix("es") {
if word.ends_with("ses") || word.ends_with("xes") {
word.to_string()
} else {
stem.to_string()
}
} else if let Some(stem) = word.strip_suffix('s') {
if word.ends_with("ss") || word.ends_with("us") {
word.to_string()
} else {
stem.to_string()
}
} else {
word.to_string()
}
}