use super::ConstraintDefinition;
use super::autodetector::{FieldState, ModelState};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[cfg_attr(doc, aquamarine::aquamarine)]
#[derive(Debug, Clone)]
pub struct ModelMetadata {
pub app_label: String,
pub model_name: String,
pub table_name: String,
pub fields: HashMap<String, FieldMetadata>,
pub options: HashMap<String, String>,
pub many_to_many_fields: Vec<ManyToManyMetadata>,
constraints: Vec<ConstraintDefinition>,
}
impl ModelMetadata {
pub fn new(
app_label: impl Into<String>,
model_name: impl Into<String>,
table_name: impl Into<String>,
) -> Self {
Self {
app_label: app_label.into(),
model_name: model_name.into(),
table_name: table_name.into(),
fields: HashMap::new(),
options: HashMap::new(),
many_to_many_fields: Vec::new(),
constraints: Vec::new(),
}
}
pub fn add_field(&mut self, name: String, field: FieldMetadata) {
self.fields.insert(name, field);
}
pub fn set_option(&mut self, key: String, value: String) {
self.options.insert(key, value);
}
pub fn add_many_to_many(&mut self, m2m: ManyToManyMetadata) {
self.many_to_many_fields.push(m2m);
}
pub fn add_constraint(&mut self, constraint: ConstraintDefinition) {
self.constraints.push(constraint);
}
pub fn constraints(&self) -> &[ConstraintDefinition] {
&self.constraints
}
pub fn to_model_state(&self) -> ModelState {
let mut model_state = ModelState::new(&self.app_label, &self.model_name);
model_state.table_name = self.table_name.clone();
for (name, field_meta) in &self.fields {
let mut field_state = FieldState::new(
name.clone(),
field_meta.field_type.clone(),
field_meta.is_nullable(),
);
for (key, value) in &field_meta.params {
field_state.params.insert(key.clone(), value.clone());
}
if let Some(ref fk_info) = field_meta.foreign_key {
field_state.foreign_key = Some(fk_info.clone());
}
model_state.add_field(field_state);
}
model_state.options = self.options.clone();
for (field_name, field_meta) in &self.fields {
if field_meta.foreign_key.is_some() {
model_state.add_foreign_key_constraint_from_field(field_name);
}
}
model_state.many_to_many_fields = self.many_to_many_fields.clone();
for (field_name, field_meta) in &self.fields {
if field_meta.params.get("unique").map(String::as_str) == Some("true") {
let constraint = ConstraintDefinition {
name: format!(
"{}_{}_{}_uniq",
self.app_label,
self.model_name.to_lowercase(),
field_name
),
constraint_type: "unique".to_string(),
fields: vec![field_name.clone()],
expression: None,
foreign_key_info: None,
};
model_state.constraints.push(constraint);
}
}
model_state
.constraints
.extend(self.constraints.iter().cloned());
model_state
}
}
#[derive(Debug, Clone)]
pub struct FieldMetadata {
pub field_type: super::FieldType,
pub params: HashMap<String, String>,
pub foreign_key: Option<super::autodetector::ForeignKeyInfo>,
}
impl FieldMetadata {
pub fn new(field_type: super::FieldType) -> Self {
Self {
field_type,
params: HashMap::new(),
foreign_key: None,
}
}
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.params.insert(key.into(), value.into());
self
}
pub fn with_nullable(mut self, nullable: bool) -> Self {
self.params.insert("null".to_string(), nullable.to_string());
self
}
pub fn is_nullable(&self) -> bool {
self.params
.get("null")
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false)
}
pub fn with_foreign_key(mut self, foreign_key: super::autodetector::ForeignKeyInfo) -> Self {
self.foreign_key = Some(foreign_key);
self
}
}
#[derive(Debug, Clone)]
pub struct RelationshipMetadata {
pub field_name: String,
pub rel_type: String,
pub to_model: Option<String>,
pub related_name: Option<String>,
pub through_table: Option<String>,
pub composite: Option<String>,
pub source_app_label: Option<String>,
pub source_model_name: Option<String>,
}
impl RelationshipMetadata {
pub fn new(field_name: impl Into<String>, rel_type: impl Into<String>) -> Self {
Self {
field_name: field_name.into(),
rel_type: rel_type.into(),
to_model: None,
related_name: None,
through_table: None,
composite: None,
source_app_label: None,
source_model_name: None,
}
}
pub fn with_to_model(mut self, to_model: impl Into<String>) -> Self {
self.to_model = Some(to_model.into());
self
}
pub fn with_related_name(mut self, related_name: impl Into<String>) -> Self {
self.related_name = Some(related_name.into());
self
}
pub fn with_through_table(mut self, through_table: impl Into<String>) -> Self {
self.through_table = Some(through_table.into());
self
}
pub fn with_composite(mut self, composite: impl Into<String>) -> Self {
self.composite = Some(composite.into());
self
}
pub fn with_source_info(
mut self,
app_label: impl Into<String>,
model_name: impl Into<String>,
) -> Self {
self.source_app_label = Some(app_label.into());
self.source_model_name = Some(model_name.into());
self
}
pub fn is_many_to_many(&self) -> bool {
self.rel_type == "many_to_many" || self.rel_type == "polymorphic_many_to_many"
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ManyToManyMetadata {
pub field_name: String,
pub to_model: String,
pub related_name: Option<String>,
pub through: Option<String>,
pub source_field: Option<String>,
pub target_field: Option<String>,
pub db_constraint_prefix: Option<String>,
}
impl ManyToManyMetadata {
pub fn new(field_name: impl Into<String>, to_model: impl Into<String>) -> Self {
Self {
field_name: field_name.into(),
to_model: to_model.into(),
related_name: None,
through: None,
source_field: None,
target_field: None,
db_constraint_prefix: None,
}
}
pub fn with_related_name(mut self, related_name: impl Into<String>) -> Self {
self.related_name = Some(related_name.into());
self
}
pub fn with_through(mut self, through: impl Into<String>) -> Self {
self.through = Some(through.into());
self
}
pub fn with_source_field(mut self, source_field: impl Into<String>) -> Self {
self.source_field = Some(source_field.into());
self
}
pub fn with_target_field(mut self, target_field: impl Into<String>) -> Self {
self.target_field = Some(target_field.into());
self
}
pub fn with_db_constraint_prefix(mut self, prefix: impl Into<String>) -> Self {
self.db_constraint_prefix = Some(prefix.into());
self
}
}
#[derive(Debug, Clone)]
pub struct ModelRegistry {
models: Arc<RwLock<HashMap<(String, String), ModelMetadata>>>,
}
impl ModelRegistry {
pub fn new() -> Self {
Self {
models: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn register_model(&self, metadata: ModelMetadata) {
let key = (metadata.app_label.clone(), metadata.model_name.clone());
if let Ok(mut models) = self.models.write() {
models.insert(key, metadata);
}
}
pub fn get_models(&self) -> Vec<ModelMetadata> {
if let Ok(models) = self.models.read() {
models.values().cloned().collect()
} else {
Vec::new()
}
}
pub fn get_model(&self, app_label: &str, model_name: &str) -> Option<ModelMetadata> {
if let Ok(models) = self.models.read() {
models
.get(&(app_label.to_string(), model_name.to_string()))
.cloned()
} else {
None
}
}
pub fn find_model_qualified(&self, app_label: &str, model_name: &str) -> Option<ModelMetadata> {
self.get_model(app_label, model_name)
}
pub fn find_model_by_name(&self, model_name: &str) -> Option<ModelMetadata> {
let models = self.models.read().ok()?;
let mut matches = models.values().filter(|m| m.model_name == model_name);
let first = matches.next()?.clone();
if matches.next().is_some() {
tracing::warn!(
model_name,
"ModelRegistry::find_model_by_name: ambiguous model name registered \
under multiple app labels; returning None. Use \
ModelRegistry::find_model_qualified(app, name) to disambiguate.",
);
return None;
}
Some(first)
}
pub fn count_models_by_name(&self, model_name: &str) -> usize {
if let Ok(models) = self.models.read() {
models
.values()
.filter(|m| m.model_name == model_name)
.count()
} else {
0
}
}
pub fn get_app_models(&self, app_label: &str) -> Vec<ModelMetadata> {
if let Ok(models) = self.models.read() {
models
.iter()
.filter(|((app, _), _)| app == app_label)
.map(|(_, meta)| meta.clone())
.collect()
} else {
Vec::new()
}
}
pub fn remove_model(&self, app_label: &str, model_name: &str) -> bool {
if let Ok(mut models) = self.models.write() {
models
.remove(&(app_label.to_string(), model_name.to_string()))
.is_some()
} else {
false
}
}
pub fn clear(&self) {
if let Ok(mut models) = self.models.write() {
models.clear();
}
}
pub fn count(&self) -> usize {
if let Ok(models) = self.models.read() {
models.len()
} else {
0
}
}
}
impl Default for ModelRegistry {
fn default() -> Self {
Self::new()
}
}
pub fn global_registry() -> &'static ModelRegistry {
use once_cell::sync::Lazy;
static REGISTRY: Lazy<ModelRegistry> = Lazy::new(ModelRegistry::new);
®ISTRY
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrations::FieldType;
use rstest::rstest;
#[test]
fn test_model_registry_new() {
let registry = ModelRegistry::new();
assert_eq!(registry.count(), 0);
}
#[test]
fn test_register_model() {
let registry = ModelRegistry::new();
let metadata = ModelMetadata::new("blog", "Post", "blog_post");
registry.register_model(metadata);
assert_eq!(registry.count(), 1);
}
#[test]
fn test_get_model() {
let registry = ModelRegistry::new();
let metadata = ModelMetadata::new("auth", "User", "auth_user");
registry.register_model(metadata);
let retrieved = registry.get_model("auth", "User");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().table_name, "auth_user");
}
#[test]
fn test_get_models() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
let models = registry.get_models();
assert_eq!(models.len(), 2);
}
#[test]
fn test_find_model_qualified_hit() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
let hit = registry.find_model_qualified("auth", "User");
assert!(hit.is_some());
let model = hit.unwrap();
assert_eq!(model.app_label, "auth");
assert_eq!(model.model_name, "User");
assert_eq!(model.table_name, "auth_user");
}
#[test]
fn test_find_model_qualified_miss_wrong_app() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
assert!(registry.find_model_qualified("billing", "User").is_none());
}
#[test]
fn test_find_model_by_name_unique() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
let hit = registry.find_model_by_name("Post");
assert!(hit.is_some());
assert_eq!(hit.unwrap().app_label, "blog");
}
#[test]
fn test_find_model_by_name_missing() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
assert!(registry.find_model_by_name("NoSuchModel").is_none());
}
#[test]
fn test_find_model_by_name_ambiguous_returns_none() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("billing", "User", "billing_user"));
let hit = registry.find_model_by_name("User");
assert!(hit.is_none());
}
#[test]
fn test_get_app_models() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("auth", "Group", "auth_group"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
let auth_models = registry.get_app_models("auth");
assert_eq!(auth_models.len(), 2);
let blog_models = registry.get_app_models("blog");
assert_eq!(blog_models.len(), 1);
}
#[test]
fn test_remove_model() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
assert!(registry.remove_model("auth", "User"));
assert_eq!(registry.count(), 0);
}
#[test]
fn test_migrations_registry_clear() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
registry.clear();
assert_eq!(registry.count(), 0);
}
#[test]
fn test_model_metadata_to_model_state() {
let mut metadata = ModelMetadata::new("blog", "Post", "blog_post");
let mut title_field = FieldMetadata::new(FieldType::Custom("CharField".to_string()));
title_field
.params
.insert("max_length".to_string(), "200".to_string());
metadata.add_field("title".to_string(), title_field);
let model_state = metadata.to_model_state();
assert_eq!(model_state.name, "Post");
assert_eq!(model_state.fields.len(), 1);
assert!(model_state.fields.contains_key("title"));
}
#[test]
fn test_field_metadata_builder() {
let field = FieldMetadata::new(FieldType::Custom("CharField".to_string()))
.with_param("max_length", "100")
.with_param("null", "False");
assert_eq!(field.field_type, FieldType::Custom("CharField".to_string()));
assert_eq!(field.params.get("max_length").unwrap(), "100");
assert_eq!(field.params.get("null").unwrap(), "False");
}
#[rstest]
#[case("true", true)]
#[case("false", false)]
fn test_to_model_state_overrides_nullable_from_params(
#[case] null_param: &str,
#[case] expected_nullable: bool,
) {
let mut metadata = ModelMetadata::new("blog", "Post", "blog_post");
let field = FieldMetadata::new(FieldType::Custom("CharField".to_string()))
.with_param("max_length", "200")
.with_param("null", null_param);
metadata.add_field("description".to_string(), field);
let model_state = metadata.to_model_state();
let field_state = model_state.fields.get("description").unwrap();
assert_eq!(field_state.nullable, expected_nullable);
}
#[rstest]
fn to_model_state_nullable_false_for_primary_key_matches_macro_contract() {
let mut metadata = ModelMetadata::new("clusters", "Cluster", "clusters");
let id_field = FieldMetadata::new(FieldType::BigInteger)
.with_param("primary_key", "true")
.with_param("auto_increment", "true")
.with_param("not_null", "true")
.with_param("null", "false");
metadata.add_field("id".to_string(), id_field);
let model_state = metadata.to_model_state();
let id_state = model_state
.fields
.get("id")
.expect("id field present in to_model_state output");
assert!(
!id_state.nullable,
"PK FieldState.nullable must be false even when the Rust type is \
Option<i64>. Did the #[model] macro regress to emitting \
null=\"true\" for Option<T> PKs? params={:?}",
id_state.params
);
assert_eq!(
id_state.params.get("null").map(String::as_str),
Some("false"),
"PK params[\"null\"] must be \"false\" (fixed macro contract). \
Got params={:?}",
id_state.params
);
}
}