rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
//! Relationship helpers and descriptors.
//!
//! Decision 29 uses composition-oriented inheritance patterns that map cleanly onto
//! SeaORM entities:
//! - Abstract base models become shared field structs embedded by concrete models.
//! - Proxy models become type aliases paired with alternative managers/query APIs.
//! - Multi-table inheritance becomes separate entities linked by foreign keys.

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

use super::{FieldType, OnDelete};

/// M2M relationship descriptor.
/// In SeaORM, M2M uses a junction entity with two belongs_to relations.
#[derive(Debug, Clone)]
pub struct ManyToManyDescriptor {
    pub from_entity: String,
    pub to_entity: String,
    pub through: Option<String>,
    pub related_name: Option<String>,
}

/// FK relationship descriptor.
#[derive(Debug, Clone)]
pub struct ForeignKeyDescriptor {
    pub to_entity: String,
    pub on_delete: OnDelete,
    pub related_name: Option<String>,
    pub db_column: Option<String>,
}

/// One-to-one relationship descriptor.
#[derive(Debug, Clone)]
pub struct OneToOneDescriptor {
    pub to_entity: String,
    pub on_delete: OnDelete,
    pub parent_link: bool,
}

pub fn validate_related_target(to: &str) -> Result<(), ValidationError> {
    let normalized = to.trim();
    if normalized.is_empty() {
        return Err(ValidationError::new(
            "Relation target cannot be empty.",
            "required",
        ));
    }

    if normalized == "self" {
        return Ok(());
    }

    let parts: Vec<_> = normalized.split('.').collect();
    let valid_shape = parts.len() == 1 || parts.len() == 2;
    let valid_identifiers = parts.iter().all(|part| {
        !part.is_empty()
            && part
                .chars()
                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
    });

    if valid_shape && valid_identifiers {
        Ok(())
    } else {
        Err(ValidationError::new(
            "Relation target must be 'Model', 'app.Model', or 'self'.",
            "invalid",
        )
        .with_param("value", normalized))
    }
}

#[must_use]
pub fn on_delete_allows_null(on_delete: OnDelete) -> bool {
    matches!(on_delete, OnDelete::SetNull)
}

#[must_use]
pub fn db_type(field: &FieldType, vendor: &str) -> Option<String> {
    let _ = vendor;

    match field {
        FieldType::ForeignKey { to, .. } => Some(format!("integer REFERENCES {to}")),
        FieldType::OneToOne { to, .. } => Some(format!("integer REFERENCES {to} UNIQUE")),
        FieldType::ManyToMany { .. } => None,
        _ => None,
    }
}

pub fn get_prep_value(value: &str) -> Result<String, ValidationError> {
    let normalized = value.trim();
    if normalized.is_empty() {
        return Err(ValidationError::new(
            "Related values cannot be empty.",
            "required",
        ));
    }

    Ok(normalized.to_string())
}

pub fn from_db_value(value: &str) -> Result<String, ValidationError> {
    get_prep_value(value)
}

#[must_use]
pub fn formfield(field: &FieldType) -> Option<&'static str> {
    match field {
        FieldType::ForeignKey { .. } | FieldType::OneToOne { .. } => Some("ModelChoiceField"),
        FieldType::ManyToMany { .. } => Some("ModelMultipleChoiceField"),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        FieldType, ForeignKeyDescriptor, ManyToManyDescriptor, OnDelete, OneToOneDescriptor,
        db_type, formfield, from_db_value, get_prep_value, on_delete_allows_null,
        validate_related_target,
    };

    #[test]
    fn validate_related_target_accepts_supported_forms() {
        for target in ["Author", "library.Author", "self"] {
            validate_related_target(target)
                .unwrap_or_else(|err| panic!("expected {target} to be accepted: {err}"));
        }
    }

    #[test]
    fn validate_related_target_rejects_invalid_forms() {
        let error = validate_related_target("library.Author.Profile")
            .expect_err("nested dotted relation should be rejected");
        assert_eq!(error.code, "invalid");
    }

    #[test]
    fn many_to_many_descriptor_tracks_junction_metadata() {
        let descriptor = ManyToManyDescriptor {
            from_entity: "Book".into(),
            to_entity: "Tag".into(),
            through: Some("book_tags".into()),
            related_name: Some("books".into()),
        };

        assert_eq!(descriptor.from_entity, "Book");
        assert_eq!(descriptor.to_entity, "Tag");
        assert_eq!(descriptor.through.as_deref(), Some("book_tags"));
        assert_eq!(descriptor.related_name.as_deref(), Some("books"));
    }

    #[test]
    fn foreign_key_descriptor_preserves_delete_behavior_and_column_name() {
        let descriptor = ForeignKeyDescriptor {
            to_entity: "Author".into(),
            on_delete: OnDelete::SetNull,
            related_name: Some("books".into()),
            db_column: Some("author_id".into()),
        };

        assert_eq!(descriptor.to_entity, "Author");
        assert_eq!(descriptor.related_name.as_deref(), Some("books"));
        assert_eq!(descriptor.db_column.as_deref(), Some("author_id"));
        assert!(on_delete_allows_null(descriptor.on_delete));
    }

    #[test]
    fn one_to_one_descriptor_preserves_parent_link_flag() {
        let descriptor = OneToOneDescriptor {
            to_entity: "Profile".into(),
            on_delete: OnDelete::Cascade,
            parent_link: true,
        };

        assert_eq!(descriptor.to_entity, "Profile");
        assert!(matches!(descriptor.on_delete, OnDelete::Cascade));
        assert!(descriptor.parent_link);
    }

    #[test]
    fn on_delete_allows_null_only_for_set_null() {
        assert!(on_delete_allows_null(OnDelete::SetNull));
        assert!(!on_delete_allows_null(OnDelete::Cascade));
    }

    #[test]
    fn db_type_for_postgres_uses_reference_clause() {
        let field = FieldType::ForeignKey {
            to: "library_author".to_string(),
            on_delete: OnDelete::Cascade,
        };
        assert_eq!(
            db_type(&field, "postgres").as_deref(),
            Some("integer REFERENCES library_author")
        );
    }

    #[test]
    fn get_prep_value_trims_related_identifiers() {
        let prepared = get_prep_value(" 42 ").expect("related values should be trimmed");
        assert_eq!(prepared, "42");
    }

    #[test]
    fn from_db_value_returns_related_identifier() {
        let parsed = from_db_value("7").expect("stored related identifiers should round-trip");
        assert_eq!(parsed, "7");
    }

    #[test]
    fn formfield_returns_multiple_choice_for_many_to_many() {
        let field = FieldType::ManyToMany {
            to: "Tag".to_string(),
            through: None,
        };
        assert_eq!(formfield(&field), Some("ModelMultipleChoiceField"));
    }
}