rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
pub mod auto;
pub mod binary;
pub mod boolean;
pub mod char;
pub mod composite;
pub mod datetime;
pub mod decimal;
pub mod duration;
pub mod email;
pub mod file;
pub mod float;
pub mod generated;
pub mod integer;
pub mod ip;
pub mod json;
pub mod mixins;
pub mod positive;
pub mod related;
pub mod related_descriptors;
pub mod related_lookups;
pub mod slug;
pub mod text;
pub mod url;
pub mod uuid;

use crate::core::validators::ValidationError;

/// Django field descriptor — maps field names to SeaORM column types.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldDescriptor {
    pub name: String,
    pub column_name: String,
    pub field_type: FieldType,
    pub null: bool,
    pub blank: bool,
    pub default: Option<String>,
    pub primary_key: bool,
    pub unique: bool,
    pub db_index: bool,
    pub max_length: Option<usize>,
    pub help_text: String,
    pub verbose_name: String,
}

impl FieldDescriptor {
    #[must_use]
    pub fn new(name: impl Into<String>, field_type: FieldType) -> Self {
        let name = name.into();
        let max_length = field_type.max_length();

        Self {
            column_name: name.clone(),
            verbose_name: default_verbose_name(&name),
            help_text: String::new(),
            default: None,
            null: false,
            blank: false,
            primary_key: false,
            unique: false,
            db_index: false,
            max_length,
            name,
            field_type,
        }
    }

    pub fn validate(&self) -> Result<(), ValidationError> {
        self.field_type.validate()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldType {
    Auto,
    BigAuto,
    SmallAuto,
    Char {
        max_length: usize,
    },
    Text,
    Integer,
    BigInteger,
    SmallInteger,
    PositiveInteger,
    PositiveBigInteger,
    PositiveSmallInteger,
    CommaSeparatedInteger {
        max_length: usize,
    },
    Float,
    Decimal {
        max_digits: u32,
        decimal_places: u32,
    },
    Boolean,
    NullBoolean,
    DateTime {
        auto_now: bool,
        auto_now_add: bool,
    },
    Date {
        auto_now: bool,
        auto_now_add: bool,
    },
    Time,
    Duration,
    Uuid,
    Json,
    Binary,
    File {
        upload_to: String,
    },
    Image {
        upload_to: String,
    },
    FilePath {
        path: String,
    },
    Email,
    Url,
    Slug {
        max_length: usize,
    },
    Ip,
    GenericIp,
    ForeignKey {
        to: String,
        on_delete: OnDelete,
    },
    OneToOne {
        to: String,
        on_delete: OnDelete,
    },
    ManyToMany {
        to: String,
        through: Option<String>,
    },
}

impl FieldType {
    #[must_use]
    pub fn max_length(&self) -> Option<usize> {
        match self {
            Self::Char { max_length }
            | Self::Slug { max_length }
            | Self::CommaSeparatedInteger { max_length } => Some(*max_length),
            _ => None,
        }
    }

    pub fn validate(&self) -> Result<(), ValidationError> {
        match self {
            Self::DateTime {
                auto_now,
                auto_now_add,
            }
            | Self::Date {
                auto_now,
                auto_now_add,
            } => datetime::validate_temporal_flags(*auto_now, *auto_now_add),
            Self::ForeignKey { to, .. }
            | Self::OneToOne { to, .. }
            | Self::ManyToMany { to, .. } => related::validate_related_target(to),
            _ => Ok(()),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnDelete {
    Cascade,
    Protect,
    SetNull,
    SetDefault,
    DoNothing,
}

fn default_verbose_name(name: &str) -> String {
    let normalized = name.replace('_', " ");
    let mut chars = normalized.chars();
    match chars.next() {
        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
        None => String::new(),
    }
}

#[cfg(test)]
mod tests {
    use super::{
        FieldDescriptor, FieldType, char::validate_char_field, datetime::validate_temporal_flags,
        float::validate_decimal_field, integer::validate_integer_field,
    };

    #[test]
    fn field_descriptor_new_sets_django_defaults() {
        let descriptor = FieldDescriptor::new("blog_title", FieldType::Char { max_length: 200 });

        assert_eq!(descriptor.name, "blog_title");
        assert_eq!(descriptor.column_name, "blog_title");
        assert_eq!(descriptor.max_length, Some(200));
        assert_eq!(descriptor.verbose_name, "Blog title");
        assert!(!descriptor.null);
        assert!(!descriptor.blank);
        assert!(!descriptor.primary_key);
    }

    #[test]
    fn char_field_validation_rejects_too_long_values() {
        let error = validate_char_field("abcdef", 5).expect_err("value should exceed max_length");
        assert_eq!(error.code, "max_length");
    }

    #[test]
    fn integer_field_validation_enforces_minimum() {
        let error =
            validate_integer_field(0, Some(1), Some(10)).expect_err("value should be below min");
        assert_eq!(error.code, "min_value");
    }

    #[test]
    fn decimal_field_validation_enforces_decimal_places() {
        let error =
            validate_decimal_field("12.345", 5, 2).expect_err("value should exceed decimal places");
        assert_eq!(error.code, "max_decimal_places");
    }

    #[test]
    fn temporal_flags_cannot_both_be_enabled() {
        let error =
            validate_temporal_flags(true, true).expect_err("auto_now flags should conflict");
        assert_eq!(error.code, "invalid");
    }
}