use crate::core::validators::ValidationError;
use super::{FieldType, OnDelete};
#[derive(Debug, Clone)]
pub struct ManyToManyDescriptor {
pub from_entity: String,
pub to_entity: String,
pub through: Option<String>,
pub related_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ForeignKeyDescriptor {
pub to_entity: String,
pub on_delete: OnDelete,
pub related_name: Option<String>,
pub db_column: Option<String>,
}
#[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"));
}
}