rust-db-blueprint 0.1.0

A Rust code generator — reads YAML draft files and generates Axum + SQLx models, migrations, handlers, routes, requests, tests, and seeds
Documentation
use indexmap::IndexMap;
use chrono::Utc;

use crate::tree::Tree;
use crate::models::ModelDef;

pub struct MigrationGenerator;

impl MigrationGenerator {
    pub fn generate(tree: &Tree) -> IndexMap<String, String> {
        let mut files = IndexMap::new();
        let now = Utc::now();
        let mut timestamp_idx = 0;

        for model in tree.all_models() {
            let ts = now.format("%Y%m%d%H%M%S").to_string();
            let unique_ts = format!("{}{:02}", &ts[..14], timestamp_idx);

            let sql = Self::generate_migration(model);
            let path = format!("migrations/{}_{}.sql", unique_ts, model.table_name());
            files.insert(path, sql);

            timestamp_idx += 1;
        }

        files
    }

    fn generate_migration(model: &ModelDef) -> String {
        let mut lines = vec![];
        let table = model.table_name();

        lines.push(format!("-- Create {} table", table));
        lines.push(format!("CREATE TABLE {} (", table));

        let mut columns = vec![];

        // Primary key
        if model.primary_key == "id" {
            columns.push("    id SERIAL PRIMARY KEY".to_string());
        } else {
            columns.push(format!("    {} SERIAL PRIMARY KEY", model.primary_key));
        }

        // User-defined columns
        for (name, column) in &model.columns {
            if name == &model.primary_key {
                continue;
            }
            let col_sql = Self::column_to_sql(name, column);
            columns.push(col_sql);
        }

        // Timestamps
        if model.timestamps {
            if !model.columns.contains_key("created_at") {
                columns.push("    created_at TIMESTAMP NOT NULL DEFAULT NOW()".to_string());
            }
            if !model.columns.contains_key("updated_at") {
                columns.push("    updated_at TIMESTAMP NOT NULL DEFAULT NOW()".to_string());
            }
        }

        // Soft deletes
        if model.has_soft_deletes() && !model.columns.contains_key("deleted_at") {
            columns.push("    deleted_at TIMESTAMP".to_string());
        }

        // Foreign keys
        for (name, column) in &model.columns {
            if column.is_foreign {
                let target = column.foreign_target
                    .clone()
                    .unwrap_or_else(|| {
                        let ref_table = name.strip_suffix("_id")
                            .map(|n| pluralize(&n.to_lowercase()))
                            .unwrap_or_else(|| "unknown".to_string());
                        format!("{}.id", ref_table)
                    });
                let ref_parts: Vec<&str> = target.splitn(2, '.').collect();
                let ref_table = ref_parts.first().unwrap_or(&"unknown");
                let ref_col = ref_parts.get(1).unwrap_or(&"id");
                columns.push(format!(
                    "    FOREIGN KEY ({}) REFERENCES {}({}) ON DELETE CASCADE",
                    name, ref_table, ref_col
                ));
            }
        }

        // Indexes
        for index in &model.indexes {
            let cols = index.columns.join(", ");
            let index_type = index.index_type.as_deref().unwrap_or("INDEX");
            if index_type == "unique" {
                columns.push(format!("    UNIQUE ({})", cols));
            } else {
                columns.push(format!("    INDEX ({})", cols));
            }
        }

        lines.push(columns.join(",\n"));
        lines.push(");".to_string());
        lines.join("\n")
    }

    fn column_to_sql(name: &str, column: &crate::models::Column) -> String {
        let sql_type = Self::data_type_to_sql(column);
        let nullable = if column.has_modifier("nullable") { "" } else { " NOT NULL" };
        let default = column.get_modifier("default")
            .and_then(|m| m.value.as_ref())
            .map(|v| format!(" DEFAULT '{}'", v))
            .unwrap_or_default();
        let unique = if column.has_modifier("unique") { " UNIQUE" } else { "" };

        format!("    {} {}{}{}{}", name, sql_type, nullable, default, unique)
    }

    fn data_type_to_sql(column: &crate::models::Column) -> String {
        match column.data_type.as_str() {
            "string" => {
                if let Some(attr) = column.attributes.first() {
                    format!("VARCHAR({})", attr)
                } else {
                    "VARCHAR(255)".to_string()
                }
            }
            "char" => {
                if let Some(attr) = column.attributes.first() {
                    format!("CHAR({})", attr)
                } else {
                    "CHAR(1)".to_string()
                }
            }
            "text" | "mediumtext" => "TEXT".to_string(),
            "longtext" => "TEXT".to_string(),
            "integer" | "unsignedInteger" => "INTEGER".to_string(),
            "bigInteger" | "unsignedBigInteger" => "BIGINT".to_string(),
            "smallInteger" | "tinyInteger" => "SMALLINT".to_string(),
            "boolean" => "BOOLEAN".to_string(),
            "float" => "FLOAT".to_string(),
            "double" => "DOUBLE PRECISION".to_string(),
            "decimal" => {
                if let Some(attr) = column.attributes.first() {
                    let parts: Vec<&str> = attr.split(',').collect();
                    if parts.len() == 2 {
                        format!("NUMERIC({}, {})", parts[0], parts[1])
                    } else {
                        format!("NUMERIC({}, 2)", attr)
                    }
                } else {
                    "NUMERIC(10, 2)".to_string()
                }
            }
            "date" => "DATE".to_string(),
            "datetime" | "datetimeTz" => "TIMESTAMP".to_string(),
            "timestamp" | "timestampTz" => "TIMESTAMP".to_string(),
            "json" | "jsonb" => "JSONB".to_string(),
            "uuid" => "UUID".to_string(),
            "ulid" => "VARCHAR(26)".to_string(),
            "ipAddress" => "INET".to_string(),
            "macAddress" => "MACADDR".to_string(),
            "enum" => {
                if let Some(attr) = column.attributes.first() {
                    let values: Vec<&str> = attr.split(',').collect();
                    let vals: Vec<String> = values.iter().map(|v| format!("'{}'", v.trim())).collect();
                    format!("VARCHAR(255) CHECK ({}.{} IN ({}))", column.name, column.name, vals.join(", "))
                } else {
                    "VARCHAR(255)".to_string()
                }
            }
            "rememberToken" => "VARCHAR(100)".to_string(),
            "softDeletes" | "softDeletesTz" => "TIMESTAMP".to_string(),
            "timestamps" | "timestampsTz" => "TIMESTAMP".to_string(),
            _ => "VARCHAR(255)".to_string(),
        }
    }
}

fn pluralize(s: &str) -> String {
    if s.ends_with('y') && !s.ends_with("ay") && !s.ends_with("ey") && !s.ends_with("oy") && !s.ends_with("uy") {
        format!("{}ies", &s[..s.len() - 1])
    } else if s.ends_with('s') || s.ends_with('x') || s.ends_with('z') || s.ends_with("ch") || s.ends_with("sh") {
        format!("{}es", s)
    } else {
        format!("{}s", s)
    }
}