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 serde_yaml::Value;

use crate::models::*;

pub struct ModelLexer;

impl ModelLexer {
    pub fn analyze(models: &IndexMap<String, Value>) -> IndexMap<String, ModelDef> {
        let mut result = IndexMap::new();

        for (name, config) in models {
            let mut model = ModelDef::new(name);

            if let Value::Mapping(mapping) = config {
                for (key, value) in mapping {
                    let key_str = key.as_str().unwrap_or("");

                    match key_str {
                        "table" => {
                            model.table = value.as_str().map(|s| s.to_string());
                        }
                        "timestamps" => {
                            model.timestamps = Self::parse_bool_or_type(value, &mut model.timestamps_type);
                        }
                        "softDeletes" => {
                            model.soft_deletes = Self::parse_bool_or_type(value, &mut model.soft_delete_type);
                        }
                        "primaryKey" | "primary_key" => {
                            model.primary_key = value.as_str().unwrap_or("id").to_string();
                        }
                        "pivot" => {
                            model.pivot = value.as_bool().unwrap_or(false);
                        }
                        "traits" => {
                            model.traits = Self::parse_list(value);
                        }
                        "indexes" => {
                            model.indexes = Self::parse_indexes(value);
                        }
                        "relationships" => {
                            model.relationships = Self::parse_relationships(value);
                        }
                        _ => {
                            // It's a column definition
                            let col_str = value.as_str().unwrap_or("string");
                            let column = Self::parse_column(key_str, col_str);
                            model.columns.insert(key_str.to_string(), column);
                        }
                    }
                }
            }

            result.insert(name.to_string(), model);
        }

        result
    }

    fn parse_column(name: &str, definition: &str) -> Column {
        let mut column = Column::new(name);
        let tokens: Vec<&str> = definition.split_whitespace().collect();

        if tokens.is_empty() {
            return column;
        }

        // First token is data type, possibly with :attribute suffix (e.g., "string:400")
        let type_part = tokens[0];
        if let Some(colon_pos) = type_part.find(':') {
            column.data_type = type_part[..colon_pos].to_string();
            let attr = type_part[colon_pos + 1..].to_string();
            column.attributes.push(attr);
        } else {
            column.data_type = type_part.to_string();
        }

        // Determine relationship type based on naming conventions
        Self::detect_relationship(name, &mut column);

        // Remaining tokens are modifiers (may contain colons, e.g. foreign:users.id)
        for token in &tokens[1..] {
            Self::add_modifier(&mut column, token);
        }

        column
    }

    fn add_modifier(column: &mut Column, token: &str) {
        match token {
            "nullable" => {
                column.modifiers.push(ColumnModifier {
                    name: "nullable".to_string(),
                    value: None,
                });
            }
            "unique" => {
                column.modifiers.push(ColumnModifier {
                    name: "unique".to_string(),
                    value: None,
                });
            }
            "unsigned" => {
                column.modifiers.push(ColumnModifier {
                    name: "unsigned".to_string(),
                    value: None,
                });
            }
            _ if token.starts_with("default:") => {
                let val = token.strip_prefix("default:").unwrap_or("");
                column.modifiers.push(ColumnModifier {
                    name: "default".to_string(),
                    value: Some(val.to_string()),
                });
            }
            "autoIncrement" => {
                column.modifiers.push(ColumnModifier {
                    name: "autoIncrement".to_string(),
                    value: None,
                });
            }
            "foreign" => {
                column.is_foreign = true;
                column.modifiers.push(ColumnModifier {
                    name: "foreign".to_string(),
                    value: None,
                });
            }
            _ if token.starts_with("foreign:") => {
                column.is_foreign = true;
                let target = token.strip_prefix("foreign:").unwrap_or("");
                column.foreign_target = Some(target.to_string());
                column.modifiers.push(ColumnModifier {
                    name: "foreign".to_string(),
                    value: Some(target.to_string()),
                });
            }
            _ if token.starts_with("onDelete:") => {
                let val = token.strip_prefix("onDelete:").unwrap_or("cascade");
                column.modifiers.push(ColumnModifier {
                    name: "onDelete".to_string(),
                    value: Some(val.to_string()),
                });
            }
            _ if token.starts_with("onUpdate:") => {
                let val = token.strip_prefix("onUpdate:").unwrap_or("cascade");
                column.modifiers.push(ColumnModifier {
                    name: "onUpdate".to_string(),
                    value: Some(val.to_string()),
                });
            }
            _ if token.starts_with("comment:") => {
                let val = token.strip_prefix("comment:").unwrap_or("");
                column.modifiers.push(ColumnModifier {
                    name: "comment".to_string(),
                    value: Some(val.to_string()),
                });
            }
            _ => {
                column.attributes.push(token.to_string());
            }
        }
    }

    fn detect_relationship(name: &str, column: &mut Column) {
        // _id suffix typically means belongsTo
        if name.ends_with("_id") {
            let model_name = name.strip_suffix("_id").unwrap_or(name);
            column.is_relationship = true;
            column.relationship_type = Some("belongsTo".to_string());
            column.relationship_model = Some(Self::snake_to_pascal(model_name));
        }
    }

    fn snake_to_pascal(s: &str) -> String {
        s.split('_')
            .filter(|p| !p.is_empty())
            .map(|p| {
                let mut c = p.chars();
                match c.next() {
                    None => String::new(),
                    Some(f) => f.to_uppercase().to_string() + c.as_str(),
                }
            })
            .collect()
    }

    fn parse_bool_or_type(value: &Value, type_field: &mut String) -> bool {
        match value {
            Value::Bool(b) => *b,
            Value::String(s) => {
                if s == "true" || s == "1" {
                    return true;
                }
                type_field.clone_from(s);
                true
            }
            _ => false,
        }
    }

    fn parse_list(value: &Value) -> Vec<String> {
        match value {
            Value::Sequence(seq) => seq.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(),
            Value::String(s) => s.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
            _ => vec![],
        }
    }

    fn parse_relationships(value: &Value) -> Vec<Relationship> {
        let mut rels = vec![];
        if let Value::Mapping(map) = value {
            for (key, val) in map {
                let rel_type = key.as_str().unwrap_or("").to_string();
                let targets = match val {
                    Value::String(s) => s.split(',').map(|s| s.trim().to_string()).collect(),
                    Value::Sequence(seq) => seq.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(),
                    _ => vec![],
                };
                for target in targets {
                    rels.push(Relationship {
                        type_: rel_type.clone(),
                        model: target,
                        foreign_key: None,
                        local_key: None,
                        table: None,
                        pivot_table: None,
                        related_key: None,
                    });
                }
            }
        }
        rels
    }

    fn parse_indexes(value: &Value) -> Vec<IndexDef> {
        let mut indexes = vec![];
        if let Value::Sequence(seq) = value {
            for item in seq {
                if let Value::Mapping(map) = item {
                    let columns = map
                        .get(&Value::String("columns".to_string()))
                        .and_then(|v| {
                            if let Value::Sequence(seq) = v {
                                Some(seq.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
                            } else {
                                v.as_str().map(|s| vec![s.to_string()])
                            }
                        })
                        .unwrap_or_default();
                    let index_type = map
                        .get(&Value::String("type".to_string()))
                        .and_then(|v| v.as_str().map(|s| s.to_string()));
                    indexes.push(IndexDef {
                        columns,
                        index_type,
                    });
                }
            }
        }
        indexes
    }
}