rust-ef-cli 1.7.0

CLI for Rust Entity Framework migrations
//! Reverse-engineer entity types from database schema (Database First scaffold).

use rust_ef::error::EFResult;
use std::path::Path;

/// A column read from database introspection (provider-agnostic).
#[derive(Debug, Clone)]
pub struct ScaffoldColumn {
    pub name: String,
    pub data_type: String,
    pub is_nullable: bool,
    pub is_primary_key: bool,
    pub max_length: Option<usize>,
}

/// A table read from database introspection.
#[derive(Debug, Clone)]
pub struct ScaffoldTable {
    pub name: String,
    pub columns: Vec<ScaffoldColumn>,
}

/// Generates Rust entity source files under `output_dir`.
pub fn write_entities(output_dir: &Path, tables: &[ScaffoldTable]) -> EFResult<()> {
    std::fs::create_dir_all(output_dir).map_err(io_err)?;
    for table in tables {
        let type_name = table_name_to_type(&table.name);
        let file_name = format!("{}.rs", snake_case(&type_name));
        let path = output_dir.join(file_name);
        std::fs::write(&path, render_entity(table, &type_name)).map_err(io_err)?;
    }

    let mod_rs = render_mod_file(tables);
    std::fs::write(output_dir.join("mod.rs"), mod_rs).map_err(io_err)?;
    Ok(())
}

fn render_mod_file(tables: &[ScaffoldTable]) -> String {
    let mut out = String::from("//! Scaffolded entity types (Database First).\n\n");
    for table in tables {
        let type_name = table_name_to_type(&table.name);
        let module = snake_case(&type_name);
        out.push_str(&format!("pub mod {module};\n"));
        out.push_str(&format!("pub use {module}::{type_name};\n"));
    }
    out
}

fn render_entity(table: &ScaffoldTable, type_name: &str) -> String {
    let mut out = String::from("//! Auto-generated by `rust-ef scaffold dbcontext`.\n\n");
    out.push_str("use rust_ef::prelude::*;\n\n");
    out.push_str("#[derive(Debug, Clone, EntityType)]\n");
    out.push_str(&format!("#[table(\"{}\")]\n", table.name));
    out.push_str(&format!("pub struct {type_name} {{\n"));

    for col in &table.columns {
        let field = column_to_field(&col.name);
        let rust_ty = map_rust_type(col);
        if col.is_primary_key {
            out.push_str("    #[primary_key]\n");
            if is_integer_type(&col.data_type) {
                out.push_str("    #[auto_increment]\n");
            }
        }
        if !col.is_nullable && !col.is_primary_key {
            out.push_str("    #[required]\n");
        }
        if let Some(n) = col.max_length {
            if rust_ty == "String" {
                out.push_str(&format!("    #[max_length({n})]\n"));
            }
        }
        out.push_str(&format!("    pub {field}: {rust_ty},\n"));
    }

    out.push_str("}\n");
    out
}

fn table_name_to_type(table: &str) -> String {
    let singular = if table.ends_with('s') && table.len() > 1 {
        &table[..table.len() - 1]
    } else {
        table
    };
    singular
        .split('_')
        .filter(|p| !p.is_empty())
        .map(|p| {
            let mut c = p.chars();
            match c.next() {
                None => String::new(),
                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
            }
        })
        .collect()
}

fn snake_case(name: &str) -> String {
    let mut out = String::new();
    for (i, ch) in name.chars().enumerate() {
        if ch.is_uppercase() {
            if i > 0 {
                out.push('_');
            }
            out.extend(ch.to_lowercase());
        } else {
            out.push(ch);
        }
    }
    out
}

fn column_to_field(column: &str) -> String {
    column.to_string()
}

fn is_integer_type(sql_type: &str) -> bool {
    let t = sql_type.to_ascii_lowercase();
    t.contains("int") || t == "serial" || t == "bigserial" || t.contains("serial")
}

fn map_rust_type(col: &ScaffoldColumn) -> String {
    let t = col.data_type.to_ascii_lowercase();
    let base = if t.contains("bool") {
        "bool"
    } else if t.contains("bigint") || t == "bigserial" {
        "i64"
    } else if t.contains("int") || t == "serial" || t.contains("serial") {
        "i32"
    } else if t.contains("double") || t.contains("decimal") || t.contains("numeric") {
        "f64"
    } else if t.contains("float") || t == "real" {
        "f32"
    } else if t.contains("byte") || t == "blob" {
        "Vec<u8>"
    } else {
        "String"
    };

    if col.is_nullable && base != "String" {
        format!("Option<{base}>")
    } else if col.is_nullable && base == "String" {
        "Option<String>".to_string()
    } else {
        base.to_string()
    }
}

fn io_err(e: std::io::Error) -> rust_ef::error::EFError {
    rust_ef::error::EFError::configuration(e.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn render_blog_entity() {
        let table = ScaffoldTable {
            name: "blogs".into(),
            columns: vec![
                ScaffoldColumn {
                    name: "blog_id".into(),
                    data_type: "integer".into(),
                    is_nullable: false,
                    is_primary_key: true,
                    max_length: None,
                },
                ScaffoldColumn {
                    name: "url".into(),
                    data_type: "character varying".into(),
                    is_nullable: false,
                    is_primary_key: false,
                    max_length: Some(200),
                },
            ],
        };
        let src = render_entity(&table, "Blog");
        assert!(src.contains("struct Blog"));
        assert!(src.contains("#[table(\"blogs\")]"));
        assert!(src.contains("#[primary_key]"));
        assert!(src.contains("#[max_length(200)]"));
    }
}