rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
use crate::db::models::fields::{FieldDescriptor, FieldType};

/// Full model meta-information, equivalent to Django's Options class.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Options {
    pub app_label: String,
    pub model_name: String,
    pub db_table: String,
    pub verbose_name: String,
    pub verbose_name_plural: String,
    pub ordering: Vec<String>,
    pub unique_together: Vec<Vec<String>>,
    pub index_together: Vec<Vec<String>>,
    pub fields: Vec<FieldDescriptor>,
    pub abstract_model: bool,
    pub managed: bool,
    pub proxy: bool,
    pub default_permissions: Vec<String>,
}

impl Options {
    #[must_use]
    pub fn new(app_label: &str, model_name: &str) -> Self {
        let normalized_model_name = to_snake_case(model_name);
        let verbose_name = normalized_model_name.replace('_', " ");

        Self {
            app_label: app_label.to_string(),
            model_name: model_name.to_string(),
            db_table: format!("{app_label}_{normalized_model_name}"),
            verbose_name: verbose_name.clone(),
            verbose_name_plural: format!("{verbose_name}s"),
            ordering: Vec::new(),
            unique_together: Vec::new(),
            index_together: Vec::new(),
            fields: Vec::new(),
            abstract_model: false,
            managed: true,
            proxy: false,
            default_permissions: ["add", "change", "delete", "view"]
                .into_iter()
                .map(str::to_string)
                .collect(),
        }
    }

    #[must_use]
    pub fn get_field(&self, name: &str) -> Option<&FieldDescriptor> {
        self.fields.iter().find(|field| field.name == name)
    }

    #[must_use]
    pub fn concrete_fields(&self) -> Vec<&FieldDescriptor> {
        self.fields
            .iter()
            .filter(|field| !is_relation_field(field))
            .collect()
    }

    #[must_use]
    pub fn related_fields(&self) -> Vec<&FieldDescriptor> {
        self.fields
            .iter()
            .filter(|field| is_relation_field(field))
            .collect()
    }

    #[must_use]
    pub fn pk_field(&self) -> Option<&FieldDescriptor> {
        self.fields.iter().find(|field| field.primary_key)
    }

    #[must_use]
    pub fn label(&self) -> String {
        format!("{}.{}", self.app_label, self.model_name)
    }

    #[must_use]
    pub fn label_lower(&self) -> String {
        self.label().to_lowercase()
    }

    #[must_use]
    pub fn field_names(&self) -> Vec<&str> {
        self.fields
            .iter()
            .map(|field| field.name.as_str())
            .collect()
    }

    #[must_use]
    pub fn many_to_many_fields(&self) -> Vec<&FieldDescriptor> {
        self.fields
            .iter()
            .filter(|field| matches!(&field.field_type, FieldType::ManyToMany { .. }))
            .collect()
    }
}

fn is_relation_field(field: &FieldDescriptor) -> bool {
    matches!(
        &field.field_type,
        FieldType::ForeignKey { .. } | FieldType::OneToOne { .. } | FieldType::ManyToMany { .. }
    )
}

fn to_snake_case(value: &str) -> String {
    let mut snake = String::with_capacity(value.len());
    let mut previous_was_lower_or_digit = false;

    for ch in value.chars() {
        if ch == '_' || ch == ' ' {
            if !snake.ends_with('_') && !snake.is_empty() {
                snake.push('_');
            }
            previous_was_lower_or_digit = false;
            continue;
        }

        if ch.is_uppercase() && previous_was_lower_or_digit {
            snake.push('_');
        }

        snake.extend(ch.to_lowercase());
        previous_was_lower_or_digit = ch.is_lowercase() || ch.is_ascii_digit();
    }

    snake
}

#[cfg(test)]
mod tests {
    use super::Options;
    use crate::db::models::fields::{FieldDescriptor, FieldType, OnDelete};

    fn scalar_field(name: &str) -> FieldDescriptor {
        FieldDescriptor::new(name, FieldType::Char { max_length: 64 })
    }

    fn pk_field(name: &str) -> FieldDescriptor {
        let mut field = FieldDescriptor::new(name, FieldType::Auto);
        field.primary_key = true;
        field
    }

    fn foreign_key(name: &str) -> FieldDescriptor {
        FieldDescriptor::new(
            name,
            FieldType::ForeignKey {
                to: "accounts.User".to_string(),
                on_delete: OnDelete::Cascade,
            },
        )
    }

    fn many_to_many(name: &str) -> FieldDescriptor {
        FieldDescriptor::new(
            name,
            FieldType::ManyToMany {
                to: "blog.Tag".to_string(),
                through: None,
            },
        )
    }

    #[test]
    fn options_basic_construction() {
        let options = Options::new("blog", "Article");

        assert_eq!(options.app_label, "blog");
        assert_eq!(options.model_name, "Article");
        assert_eq!(options.db_table, "blog_article");
        assert_eq!(options.verbose_name, "article");
        assert_eq!(options.verbose_name_plural, "articles");
        assert_eq!(
            options.default_permissions,
            vec!["add", "change", "delete", "view"]
        );
    }

    #[test]
    fn get_field_finds_by_name() {
        let mut options = Options::new("blog", "Article");
        options.fields.push(scalar_field("title"));

        let field = options.get_field("title").expect("field should exist");
        assert_eq!(field.name, "title");
    }

    #[test]
    fn pk_field_returns_primary_key() {
        let mut options = Options::new("blog", "Article");
        options.fields.push(scalar_field("title"));
        options.fields.push(pk_field("id"));

        let field = options.pk_field().expect("primary key should exist");
        assert_eq!(field.name, "id");
    }

    #[test]
    fn concrete_fields_excludes_relations() {
        let mut options = Options::new("blog", "Article");
        options.fields.push(pk_field("id"));
        options.fields.push(scalar_field("title"));
        options.fields.push(foreign_key("author"));
        options.fields.push(many_to_many("tags"));

        let names: Vec<_> = options
            .concrete_fields()
            .into_iter()
            .map(|field| field.name.as_str())
            .collect();
        assert_eq!(names, vec!["id", "title"]);
    }

    #[test]
    fn related_fields_only_relations() {
        let mut options = Options::new("blog", "Article");
        options.fields.push(pk_field("id"));
        options.fields.push(scalar_field("title"));
        options.fields.push(foreign_key("author"));
        options.fields.push(many_to_many("tags"));

        let names: Vec<_> = options
            .related_fields()
            .into_iter()
            .map(|field| field.name.as_str())
            .collect();
        assert_eq!(names, vec!["author", "tags"]);
    }

    #[test]
    fn label_formats_correctly() {
        let options = Options::new("blog", "Article");

        assert_eq!(options.label(), "blog.Article");
        assert_eq!(options.label_lower(), "blog.article");
    }

    #[test]
    fn field_names_return_declared_order() {
        let mut options = Options::new("blog", "Article");
        options.fields.push(pk_field("id"));
        options.fields.push(scalar_field("title"));
        options.fields.push(foreign_key("author"));

        assert_eq!(options.field_names(), vec!["id", "title", "author"]);
    }

    #[test]
    fn many_to_many_fields_only_return_many_to_many_descriptors() {
        let mut options = Options::new("blog", "Article");
        options.fields.push(pk_field("id"));
        options.fields.push(foreign_key("author"));
        options.fields.push(many_to_many("tags"));

        let names: Vec<_> = options
            .many_to_many_fields()
            .into_iter()
            .map(|field| field.name.as_str())
            .collect();
        assert_eq!(names, vec!["tags"]);
    }
}