rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
use sea_orm::sea_query::{Alias, Expr, ExprTrait, SimpleExpr};

use crate::db::models::constants::LOOKUP_SEP;

const KNOWN_LOOKUPS: &[&str] = &[
    "exact",
    "iexact",
    "contains",
    "icontains",
    "gt",
    "gte",
    "lt",
    "lte",
    "startswith",
    "endswith",
    "in",
    "isnull",
    "range",
    "regex",
    "iregex",
];

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LookupPath {
    pub relations: Vec<String>,
    pub field: String,
    pub lookup: String,
}

/// Build a lookup across a relationship.
#[must_use]
pub fn related_lookup(relation_path: &str, field: &str, value: SimpleExpr) -> SimpleExpr {
    let column = if relation_path.is_empty() {
        field.to_string()
    } else {
        format!("{relation_path}.{field}")
    };
    Expr::col(Alias::new(&column)).eq(value)
}

/// Parse a double-underscore lookup path into relation chain, field, and lookup type.
#[must_use]
pub fn parse_lookup_path(path: &str) -> LookupPath {
    if path.is_empty() {
        return LookupPath {
            relations: Vec::new(),
            field: String::new(),
            lookup: "exact".to_string(),
        };
    }

    let parts: Vec<&str> = path.split(LOOKUP_SEP).collect();
    match parts.len() {
        0 => LookupPath {
            relations: Vec::new(),
            field: String::new(),
            lookup: "exact".to_string(),
        },
        1 => LookupPath {
            relations: Vec::new(),
            field: parts[0].to_string(),
            lookup: "exact".to_string(),
        },
        _ => {
            let last = parts[parts.len() - 1];
            if KNOWN_LOOKUPS.contains(&last) {
                LookupPath {
                    relations: parts[..parts.len() - 2]
                        .iter()
                        .map(|part| (*part).to_string())
                        .collect(),
                    field: parts[parts.len() - 2].to_string(),
                    lookup: last.to_string(),
                }
            } else {
                LookupPath {
                    relations: parts[..parts.len() - 1]
                        .iter()
                        .map(|part| (*part).to_string())
                        .collect(),
                    field: last.to_string(),
                    lookup: "exact".to_string(),
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use sea_orm::Condition;
    use sea_orm::sea_query::{Alias, Expr, Query, SimpleExpr, SqliteQueryBuilder};

    use super::{LookupPath, parse_lookup_path, related_lookup};

    fn render_where(expr: SimpleExpr) -> String {
        Query::select()
            .column(Alias::new("id"))
            .from(Alias::new("widgets"))
            .cond_where(Condition::all().add(expr))
            .to_owned()
            .to_string(SqliteQueryBuilder)
    }

    #[test]
    fn parse_lookup_path_handles_empty_path() {
        assert_eq!(
            parse_lookup_path(""),
            LookupPath {
                relations: vec![],
                field: String::new(),
                lookup: "exact".to_string(),
            }
        );
    }

    #[test]
    fn parse_lookup_path_simple_field_defaults_to_exact() {
        assert_eq!(
            parse_lookup_path("title"),
            LookupPath {
                relations: vec![],
                field: "title".to_string(),
                lookup: "exact".to_string(),
            }
        );
    }

    #[test]
    fn parse_lookup_path_field_and_lookup() {
        assert_eq!(
            parse_lookup_path("title__icontains"),
            LookupPath {
                relations: vec![],
                field: "title".to_string(),
                lookup: "icontains".to_string(),
            }
        );
    }

    #[test]
    fn parse_lookup_path_relation_and_field() {
        assert_eq!(
            parse_lookup_path("author__name"),
            LookupPath {
                relations: vec!["author".to_string()],
                field: "name".to_string(),
                lookup: "exact".to_string(),
            }
        );
    }

    #[test]
    fn parse_lookup_path_relation_field_and_lookup() {
        assert_eq!(
            parse_lookup_path("author__name__contains"),
            LookupPath {
                relations: vec!["author".to_string()],
                field: "name".to_string(),
                lookup: "contains".to_string(),
            }
        );
    }

    #[test]
    fn parse_lookup_path_handles_deep_relation_chain() {
        assert_eq!(
            parse_lookup_path("publisher__country__region__name__iexact"),
            LookupPath {
                relations: vec![
                    "publisher".to_string(),
                    "country".to_string(),
                    "region".to_string(),
                ],
                field: "name".to_string(),
                lookup: "iexact".to_string(),
            }
        );
    }

    #[test]
    fn parse_lookup_path_unknown_suffix_defaults_to_exact_lookup() {
        assert_eq!(
            parse_lookup_path("author__name__customlookup"),
            LookupPath {
                relations: vec!["author".to_string(), "name".to_string()],
                field: "customlookup".to_string(),
                lookup: "exact".to_string(),
            }
        );
    }

    #[test]
    fn parse_lookup_path_recognizes_isnull_lookup() {
        assert_eq!(
            parse_lookup_path("author__deleted_at__isnull"),
            LookupPath {
                relations: vec!["author".to_string()],
                field: "deleted_at".to_string(),
                lookup: "isnull".to_string(),
            }
        );
    }

    #[test]
    fn parse_lookup_path_recognizes_in_lookup() {
        assert_eq!(
            parse_lookup_path("author__id__in"),
            LookupPath {
                relations: vec!["author".to_string()],
                field: "id".to_string(),
                lookup: "in".to_string(),
            }
        );
    }

    #[test]
    fn related_lookup_produces_expression_with_relation_alias() {
        let sql = render_where(related_lookup("author", "name", Expr::val("Alice")));
        assert!(sql.contains("author.name"), "unexpected SQL: {sql}");
        assert!(sql.contains("Alice"), "unexpected SQL: {sql}");
    }
}