rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
use std::collections::{HashMap, HashSet};

use crate::db::models::fields::{FieldDescriptor, FieldType};
use crate::forms::fields::{
    BooleanField, CharField, DateField, DateTimeField, DecimalField, DurationField, EmailField,
    FileField, FilePathField, FloatField, FormField, GenericIPAddressField, ImageField,
    IntegerField, JSONField, SlugField, TimeField, URLField,
};
use crate::forms::forms::Form;

/// Configuration for generating a form from model fields.
#[derive(Debug, Clone)]
pub struct ModelFormConfig {
    /// Model name retained for diagnostics.
    pub model_name: String,
    /// Field descriptors from the model.
    pub fields: Vec<FieldDescriptor>,
    /// Which fields to include (None = all).
    pub include_fields: Option<Vec<String>>,
    /// Which fields to exclude.
    pub exclude_fields: Vec<String>,
}

impl ModelFormConfig {
    #[must_use]
    pub fn new(model_name: impl Into<String>, fields: Vec<FieldDescriptor>) -> Self {
        Self {
            model_name: model_name.into(),
            fields,
            include_fields: None,
            exclude_fields: Vec::new(),
        }
    }

    #[must_use]
    pub fn include(mut self, fields: Vec<String>) -> Self {
        self.include_fields = Some(fields);
        self
    }

    #[must_use]
    pub fn exclude(mut self, fields: Vec<String>) -> Self {
        self.exclude_fields.extend(fields);
        self
    }
}

/// Maps a model field type to the closest available form field.
#[must_use]
pub fn field_type_to_form_field(field: &FieldDescriptor) -> Box<dyn FormField> {
    let required = !field.blank;
    let text_field = |max_length| -> Box<dyn FormField> {
        Box::new(CharField {
            max_length,
            required,
            strip: true,
            ..Default::default()
        })
    };

    match &field.field_type {
        FieldType::Char { max_length } => text_field(Some(*max_length)),
        FieldType::Text => text_field(None),
        FieldType::Integer | FieldType::BigInteger | FieldType::SmallInteger => {
            Box::new(IntegerField {
                required,
                ..Default::default()
            })
        }
        FieldType::PositiveInteger
        | FieldType::PositiveBigInteger
        | FieldType::PositiveSmallInteger => Box::new(IntegerField {
            min_value: Some(0),
            required,
            ..Default::default()
        }),
        FieldType::CommaSeparatedInteger { max_length } => text_field(Some(*max_length)),
        FieldType::Float => Box::new(FloatField {
            required,
            ..Default::default()
        }),
        FieldType::Decimal {
            max_digits,
            decimal_places,
        } => Box::new(DecimalField {
            required,
            max_digits: Some(*max_digits as usize),
            decimal_places: Some(*decimal_places as usize),
        }),
        FieldType::Boolean => Box::new(BooleanField { required }),
        FieldType::NullBoolean => Box::new(BooleanField { required: false }),
        FieldType::DateTime { .. } => Box::new(DateTimeField { required }),
        FieldType::Date { .. } => Box::new(DateField {
            required,
            ..Default::default()
        }),
        FieldType::Time => Box::new(TimeField {
            required,
            ..Default::default()
        }),
        FieldType::Duration => Box::new(DurationField { required }),
        FieldType::Uuid => text_field(Some(36)),
        FieldType::Json => Box::new(JSONField { required }),
        FieldType::File { .. } => Box::new(FileField {
            required,
            max_length: field.max_length,
            ..Default::default()
        }),
        FieldType::Image { .. } => Box::new(ImageField {
            required,
            max_length: field.max_length,
            ..Default::default()
        }),
        FieldType::FilePath { path } => Box::new(FilePathField {
            path: path.clone(),
            required,
            ..Default::default()
        }),
        FieldType::Email => Box::new(EmailField { required }),
        FieldType::Url => Box::new(URLField { required }),
        FieldType::Slug { .. } => Box::new(SlugField { required }),
        FieldType::Ip | FieldType::GenericIp => Box::new(GenericIPAddressField {
            required,
            ..Default::default()
        }),
        FieldType::Auto
        | FieldType::BigAuto
        | FieldType::SmallAuto
        | FieldType::Binary
        | FieldType::ForeignKey { .. }
        | FieldType::OneToOne { .. }
        | FieldType::ManyToMany { .. } => text_field(None),
    }
}

/// Generate a `Form` from a [`ModelFormConfig`].
#[must_use]
pub fn modelform_factory(config: &ModelFormConfig) -> Form {
    let include_fields = config
        .include_fields
        .as_ref()
        .map(|fields| fields.iter().map(String::as_str).collect::<HashSet<&str>>());
    let exclude_fields = config
        .exclude_fields
        .iter()
        .map(String::as_str)
        .collect::<HashSet<&str>>();

    let mut form_fields: HashMap<String, Box<dyn FormField>> = HashMap::new();
    for field_desc in &config.fields {
        if matches!(
            &field_desc.field_type,
            FieldType::Auto | FieldType::BigAuto | FieldType::SmallAuto
        ) {
            continue;
        }

        let field_name = field_desc.name.as_str();
        if exclude_fields.contains(field_name) {
            continue;
        }
        if let Some(include_fields) = &include_fields
            && !include_fields.contains(field_name)
        {
            continue;
        }

        form_fields.insert(
            field_desc.name.clone(),
            field_type_to_form_field(field_desc),
        );
    }

    Form::new(form_fields)
}

/// Bind existing model data to a form for editing.
#[must_use]
pub fn bind_instance(form: Form, instance: &HashMap<String, String>) -> Form {
    form.bind(instance.clone())
}

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

    use std::collections::HashMap;

    fn descriptor(name: &str, field_type: FieldType) -> FieldDescriptor {
        FieldDescriptor::new(name, field_type)
    }

    fn optional_descriptor(name: &str, field_type: FieldType) -> FieldDescriptor {
        let mut descriptor = descriptor(name, field_type);
        descriptor.blank = true;
        descriptor
    }

    #[test]
    fn modelform_factory_creates_fields_from_descriptors() {
        let config = ModelFormConfig::new(
            "Article",
            vec![
                descriptor("title", FieldType::Char { max_length: 255 }),
                descriptor("email", FieldType::Email),
                descriptor("rating", FieldType::Float),
                optional_descriptor(
                    "published_at",
                    FieldType::DateTime {
                        auto_now: false,
                        auto_now_add: false,
                    },
                ),
            ],
        );

        let form = modelform_factory(&config);

        assert_eq!(form.fields.len(), 4);
        assert_eq!(form.fields["title"].widget_type(), "text");
        assert!(form.fields["title"].required());
        assert_eq!(form.fields["email"].widget_type(), "email");
        assert_eq!(form.fields["rating"].widget_type(), "number");
        assert_eq!(form.fields["published_at"].widget_type(), "datetime-local");
        assert!(!form.fields["published_at"].required());
    }

    #[test]
    fn modelform_factory_excludes_auto_fields() {
        let config = ModelFormConfig::new(
            "Article",
            vec![
                descriptor("id", FieldType::Auto),
                descriptor("big_id", FieldType::BigAuto),
                descriptor("small_id", FieldType::SmallAuto),
                descriptor("title", FieldType::Char { max_length: 255 }),
            ],
        );

        let form = modelform_factory(&config);

        assert_eq!(form.fields.len(), 1);
        assert!(form.fields.contains_key("title"));
        assert!(!form.fields.contains_key("id"));
        assert!(!form.fields.contains_key("big_id"));
        assert!(!form.fields.contains_key("small_id"));
    }

    #[test]
    fn modelform_factory_respects_include() {
        let config = ModelFormConfig::new(
            "Article",
            vec![
                descriptor("title", FieldType::Char { max_length: 255 }),
                descriptor("body", FieldType::Text),
                descriptor("email", FieldType::Email),
            ],
        )
        .include(vec!["email".to_string(), "body".to_string()]);

        let form = modelform_factory(&config);

        assert_eq!(form.fields.len(), 2);
        assert!(form.fields.contains_key("body"));
        assert!(form.fields.contains_key("email"));
        assert!(!form.fields.contains_key("title"));
    }

    #[test]
    fn modelform_factory_respects_exclude() {
        let config = ModelFormConfig::new(
            "Article",
            vec![
                descriptor("title", FieldType::Char { max_length: 255 }),
                descriptor("body", FieldType::Text),
                descriptor("email", FieldType::Email),
            ],
        )
        .include(vec!["title".to_string(), "email".to_string()])
        .exclude(vec!["email".to_string()]);

        let form = modelform_factory(&config);

        assert_eq!(form.fields.len(), 1);
        assert!(form.fields.contains_key("title"));
        assert!(!form.fields.contains_key("body"));
        assert!(!form.fields.contains_key("email"));
    }

    #[test]
    fn field_type_to_form_field_maps_correctly() {
        let char_field =
            field_type_to_form_field(&descriptor("title", FieldType::Char { max_length: 5 }));
        assert_eq!(char_field.widget_type(), "text");
        assert_eq!(
            char_field.clean("  abc  ").expect("char should clean"),
            "abc"
        );
        assert!(char_field.clean("abcdef").is_err());

        let integer_field = field_type_to_form_field(&descriptor("count", FieldType::Integer));
        assert_eq!(integer_field.widget_type(), "number");
        assert_eq!(integer_field.clean("42").expect("int should clean"), "42");

        let boolean_field = field_type_to_form_field(&descriptor("active", FieldType::Boolean));
        assert_eq!(boolean_field.widget_type(), "checkbox");

        let email_field = field_type_to_form_field(&descriptor("email", FieldType::Email));
        assert_eq!(email_field.widget_type(), "email");
        assert!(email_field.clean("user@example.com").is_ok());

        let url_field = field_type_to_form_field(&descriptor("site", FieldType::Url));
        assert_eq!(url_field.widget_type(), "url");
        assert!(url_field.clean("https://example.com").is_ok());

        let slug_field =
            field_type_to_form_field(&descriptor("slug", FieldType::Slug { max_length: 50 }));
        assert_eq!(slug_field.widget_type(), "text");
        assert!(slug_field.clean("valid-slug").is_ok());
        assert!(slug_field.clean("not a slug").is_err());

        let datetime_field = field_type_to_form_field(&descriptor(
            "published_at",
            FieldType::DateTime {
                auto_now: false,
                auto_now_add: false,
            },
        ));
        assert_eq!(datetime_field.widget_type(), "datetime-local");

        let json_field = field_type_to_form_field(&descriptor("payload", FieldType::Json));
        assert_eq!(json_field.widget_type(), "textarea");

        let fallback_field = field_type_to_form_field(&descriptor("blob", FieldType::Binary));
        assert_eq!(fallback_field.widget_type(), "text");
        assert_eq!(
            fallback_field
                .clean("opaque")
                .expect("fallback should clean"),
            "opaque"
        );
    }

    #[test]
    fn bind_instance_populates_data() {
        let config = ModelFormConfig::new(
            "Article",
            vec![
                descriptor("title", FieldType::Char { max_length: 255 }),
                descriptor("email", FieldType::Email),
            ],
        );
        let instance = HashMap::from([
            ("title".to_string(), "  Hello world  ".to_string()),
            ("email".to_string(), "editor@example.com".to_string()),
        ]);

        let mut form = bind_instance(modelform_factory(&config), &instance);

        assert!(form.is_bound);
        assert_eq!(form.data.get("title"), Some(&"  Hello world  ".to_string()));
        assert_eq!(
            form.data.get("email"),
            Some(&"editor@example.com".to_string())
        );
        assert!(form.is_valid());
        assert_eq!(
            form.cleaned_data().get("title"),
            Some(&"Hello world".to_string())
        );
        assert_eq!(
            form.cleaned_data().get("email"),
            Some(&"editor@example.com".to_string())
        );
    }
}