Skip to main content

reinhardt_db/migrations/
model_registry.rs

1//! Global model registry for Reinhardt migrations
2//!
3//! This module provides a Django-like model registration system that allows
4//! models to be registered globally and accessed during migration generation.
5//!
6//! # Django Reference
7//! Django's app registry is implemented in `django/apps/registry.py` and provides:
8//! - Global model registration via `Apps.register_model()`
9//! - Model retrieval via `Apps.get_models()`
10//! - Thread-safe access with RwLock
11//!
12//! See [`ModelMetadata`] for the architecture comparison diagram.
13
14use super::ConstraintDefinition;
15use super::autodetector::{FieldState, ModelState};
16use std::collections::HashMap;
17use std::sync::{Arc, RwLock};
18
19#[cfg_attr(doc, aquamarine::aquamarine)]
20/// Model metadata for registration
21///
22/// # Architecture
23///
24/// This struct mirrors Django's model registration pattern:
25///
26/// ```mermaid
27/// graph LR
28///     subgraph Django["Django (Reference)"]
29///         Apps["Apps"]
30///         Apps --> all_models["all_models"]
31///         Apps --> register_model["register_model()"]
32///         Apps --> get_models["get_models()"]
33///     end
34///
35///     subgraph Reinhardt["Reinhardt"]
36///         ModelRegistry["ModelRegistry"]
37///         ModelRegistry --> models["models"]
38///         ModelRegistry --> register_model2["register_model()"]
39///         ModelRegistry --> get_models2["get_models()"]
40///         ModelRegistry --> get_model["get_model()"]
41///     end
42///
43///     Django -.-> Reinhardt
44/// ```
45#[derive(Debug, Clone)]
46pub struct ModelMetadata {
47	/// Application label (e.g., "auth", "blog")
48	pub app_label: String,
49	/// Model name (e.g., "User", "Post")
50	pub model_name: String,
51	/// Table name (e.g., "auth_user", "blog_post")
52	pub table_name: String,
53	/// Field definitions
54	pub fields: HashMap<String, FieldMetadata>,
55	/// Model options (e.g., db_table, ordering)
56	pub options: HashMap<String, String>,
57	/// ManyToMany relationship definitions
58	pub many_to_many_fields: Vec<ManyToManyMetadata>,
59	/// Model-level constraints declared via `#[model(unique_together = ...)]`
60	/// and other peer-constraint attributes. Field-level `unique = true` is
61	/// still synthesized inside `to_model_state()` and not stored here, to
62	/// preserve the existing single-field UNIQUE behavior.
63	///
64	/// Kept private so that adding the field to a previously
65	/// externally-constructible struct does not break the public API.
66	/// Read via [`Self::constraints`]; write via [`Self::add_constraint`].
67	constraints: Vec<ConstraintDefinition>,
68}
69
70impl ModelMetadata {
71	/// Creates a new instance.
72	pub fn new(
73		app_label: impl Into<String>,
74		model_name: impl Into<String>,
75		table_name: impl Into<String>,
76	) -> Self {
77		Self {
78			app_label: app_label.into(),
79			model_name: model_name.into(),
80			table_name: table_name.into(),
81			fields: HashMap::new(),
82			options: HashMap::new(),
83			many_to_many_fields: Vec::new(),
84			constraints: Vec::new(),
85		}
86	}
87
88	/// Adds field.
89	pub fn add_field(&mut self, name: String, field: FieldMetadata) {
90		self.fields.insert(name, field);
91	}
92
93	/// Sets the option.
94	pub fn set_option(&mut self, key: String, value: String) {
95		self.options.insert(key, value);
96	}
97
98	/// Adds many to many.
99	pub fn add_many_to_many(&mut self, m2m: ManyToManyMetadata) {
100		self.many_to_many_fields.push(m2m);
101	}
102
103	/// Adds a model-level constraint declared via macro attributes
104	/// (e.g., `#[model(unique_together = ...)]`).
105	pub fn add_constraint(&mut self, constraint: ConstraintDefinition) {
106		self.constraints.push(constraint);
107	}
108
109	/// Returns model-level constraints registered by the `#[model(...)]`
110	/// macro (currently composite UNIQUE from `unique_together`).
111	///
112	/// Field-level `unique = true` is not included here; it is synthesized
113	/// inside [`Self::to_model_state`] from `FieldMetadata` parameters.
114	pub fn constraints(&self) -> &[ConstraintDefinition] {
115		&self.constraints
116	}
117
118	/// Convert to ModelState for migrations
119	///
120	/// # Examples
121	///
122	/// ```rust,ignore
123	/// use reinhardt_db::migrations::model_registry::{ModelMetadata, FieldMetadata};
124	/// use reinhardt_db::migrations::FieldType;
125	///
126	/// let mut metadata = ModelMetadata::new("myapp", "User", "myapp_user");
127	/// metadata.add_field(
128	///     "email".to_string(),
129	///     FieldMetadata::new(FieldType::VarChar(255)).with_param("max_length", "255"),
130	/// );
131	///
132	/// let model_state = metadata.to_model_state();
133	/// assert_eq!(model_state.app_label, "myapp");
134	/// assert_eq!(model_state.name, "User");
135	/// assert!(model_state.has_field("email"));
136	/// ```
137	pub fn to_model_state(&self) -> ModelState {
138		let mut model_state = ModelState::new(&self.app_label, &self.model_name);
139
140		// Set the correct table name from metadata
141		// This overrides the default snake_case conversion in ModelState::new
142		model_state.table_name = self.table_name.clone();
143
144		// Convert fields
145		for (name, field_meta) in &self.fields {
146			let mut field_state = FieldState::new(
147				name.clone(),
148				field_meta.field_type.clone(),
149				field_meta.nullable,
150			);
151			for (key, value) in &field_meta.params {
152				if key == "null" {
153					continue;
154				}
155				field_state.params.insert(key.clone(), value.clone());
156			}
157			// Set ForeignKey information if present
158			if let Some(ref fk_info) = field_meta.foreign_key {
159				field_state.foreign_key = Some(fk_info.clone());
160			}
161			model_state.add_field(field_state);
162		}
163
164		// Copy options
165		model_state.options = self.options.clone();
166
167		// Generate ForeignKey constraints from fields
168		for (field_name, field_meta) in &self.fields {
169			if field_meta.foreign_key.is_some() {
170				model_state.add_foreign_key_constraint_from_field(field_name);
171			}
172		}
173
174		// Copy ManyToMany relationship metadata
175		model_state.many_to_many_fields = self.many_to_many_fields.clone();
176
177		// Generate Unique constraints from field params
178		for (field_name, field_meta) in &self.fields {
179			if field_meta.params.get("unique").map(String::as_str) == Some("true") {
180				let constraint = ConstraintDefinition {
181					name: format!(
182						"{}_{}_{}_uniq",
183						self.app_label,
184						self.model_name.to_lowercase(),
185						field_name
186					),
187					constraint_type: "unique".to_string(),
188					fields: vec![field_name.clone()],
189					expression: None,
190					foreign_key_info: None,
191				};
192				model_state.constraints.push(constraint);
193			}
194		}
195
196		// Copy model-level constraints declared via #[model(unique_together = ...)]
197		// (and other peer-constraint attributes). These are populated by the
198		// derive macro at registration time. See reinhardt-web#4022.
199		model_state
200			.constraints
201			.extend(self.constraints.iter().cloned());
202
203		model_state
204	}
205}
206
207/// Field metadata for registration
208#[derive(Debug, Clone)]
209pub struct FieldMetadata {
210	/// Field type (e.g., CharField, IntegerField, ForeignKey)
211	pub field_type: super::FieldType,
212	/// Whether this field is nullable (`NULL` is allowed).
213	///
214	/// This is the canonical source of truth. [`Self::is_nullable`]
215	/// returns this value directly. [`Self::with_nullable`] sets both
216	/// this field and syncs `params["null"]` for backward compatibility.
217	pub nullable: bool,
218	/// Field parameters (max_length, blank, default, etc.)
219	pub params: HashMap<String, String>,
220	/// ForeignKey information if this field is a foreign key
221	pub foreign_key: Option<super::autodetector::ForeignKeyInfo>,
222}
223
224impl FieldMetadata {
225	/// Creates a new instance.
226	pub fn new(field_type: super::FieldType) -> Self {
227		Self {
228			field_type,
229			nullable: false,
230			params: HashMap::new(),
231			foreign_key: None,
232		}
233	}
234
235	/// Sets the param and returns self for chaining.
236	///
237	/// When `key` is `"null"`, the value is parsed as a bool and
238	/// [`Self::nullable`] is synced automatically to prevent silent
239	/// divergence between the struct field and `params["null"]`.
240	/// Prefer [`Self::with_nullable`] for new code.
241	pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
242		let key_s: String = key.into();
243		let value_s: String = value.into();
244		if key_s == "null" {
245			let parsed = value_s.parse::<bool>().unwrap_or(false);
246			self.nullable = parsed;
247			self.params.insert(key_s, parsed.to_string());
248			return self;
249		}
250		self.params.insert(key_s, value_s);
251		self
252	}
253
254	/// Sets the nullability and returns self for chaining.
255	///
256	/// Sets both [`Self::nullable`] (canonical) and `params["null"]`
257	/// (backward compatibility).
258	pub fn with_nullable(mut self, nullable: bool) -> Self {
259		self.nullable = nullable;
260		self.params.insert("null".to_string(), nullable.to_string());
261		self
262	}
263
264	/// Returns whether the column is nullable (i.e., `NULL` is allowed).
265	pub fn is_nullable(&self) -> bool {
266		self.nullable
267	}
268
269	/// Sets the foreign key and returns self for chaining.
270	pub fn with_foreign_key(mut self, foreign_key: super::autodetector::ForeignKeyInfo) -> Self {
271		self.foreign_key = Some(foreign_key);
272		self
273	}
274}
275
276/// Relationship metadata for `#[rel]` attributes
277///
278/// This structure holds metadata about relationships defined on model fields
279/// using the `#[rel(...)]` attribute.
280#[derive(Debug, Clone)]
281pub struct RelationshipMetadata {
282	/// Field name
283	pub field_name: String,
284	/// Relationship type (foreign_key, one_to_one, many_to_many, etc.)
285	pub rel_type: String,
286	/// Target model (e.g., "User", "auth.User")
287	pub to_model: Option<String>,
288	/// Related name for reverse accessor
289	pub related_name: Option<String>,
290	/// Through table name (for ManyToMany)
291	pub through_table: Option<String>,
292	/// Composite struct name (for additional through table fields)
293	pub composite: Option<String>,
294	/// Source model app label (for generating Through table foreign keys)
295	pub source_app_label: Option<String>,
296	/// Source model name (for generating Through table foreign keys)
297	pub source_model_name: Option<String>,
298}
299
300impl RelationshipMetadata {
301	/// Create a new RelationshipMetadata
302	pub fn new(field_name: impl Into<String>, rel_type: impl Into<String>) -> Self {
303		Self {
304			field_name: field_name.into(),
305			rel_type: rel_type.into(),
306			to_model: None,
307			related_name: None,
308			through_table: None,
309			composite: None,
310			source_app_label: None,
311			source_model_name: None,
312		}
313	}
314
315	/// Set target model
316	pub fn with_to_model(mut self, to_model: impl Into<String>) -> Self {
317		self.to_model = Some(to_model.into());
318		self
319	}
320
321	/// Set related name
322	pub fn with_related_name(mut self, related_name: impl Into<String>) -> Self {
323		self.related_name = Some(related_name.into());
324		self
325	}
326
327	/// Set through table name
328	pub fn with_through_table(mut self, through_table: impl Into<String>) -> Self {
329		self.through_table = Some(through_table.into());
330		self
331	}
332
333	/// Set composite struct name
334	pub fn with_composite(mut self, composite: impl Into<String>) -> Self {
335		self.composite = Some(composite.into());
336		self
337	}
338
339	/// Set source model information
340	pub fn with_source_info(
341		mut self,
342		app_label: impl Into<String>,
343		model_name: impl Into<String>,
344	) -> Self {
345		self.source_app_label = Some(app_label.into());
346		self.source_model_name = Some(model_name.into());
347		self
348	}
349
350	/// Check if this is a ManyToMany relationship
351	pub fn is_many_to_many(&self) -> bool {
352		self.rel_type == "many_to_many" || self.rel_type == "polymorphic_many_to_many"
353	}
354}
355
356/// ManyToMany relationship metadata
357///
358/// This structure holds specific metadata for ManyToMany relationships,
359/// including through table information and custom field names.
360#[derive(Debug, Clone, PartialEq)]
361pub struct ManyToManyMetadata {
362	/// Field name
363	pub field_name: String,
364	/// Target model name (e.g., "Group", "User")
365	pub to_model: String,
366	/// Related name for reverse accessor
367	pub related_name: Option<String>,
368	/// Custom through table name (if specified)
369	pub through: Option<String>,
370	/// Source field name in through table (defaults to "{source_model}_id")
371	pub source_field: Option<String>,
372	/// Target field name in through table (defaults to "{target_model}_id")
373	pub target_field: Option<String>,
374	/// Database constraint prefix
375	pub db_constraint_prefix: Option<String>,
376}
377
378impl ManyToManyMetadata {
379	/// Create a new ManyToManyMetadata
380	pub fn new(field_name: impl Into<String>, to_model: impl Into<String>) -> Self {
381		Self {
382			field_name: field_name.into(),
383			to_model: to_model.into(),
384			related_name: None,
385			through: None,
386			source_field: None,
387			target_field: None,
388			db_constraint_prefix: None,
389		}
390	}
391
392	/// Set related name
393	pub fn with_related_name(mut self, related_name: impl Into<String>) -> Self {
394		self.related_name = Some(related_name.into());
395		self
396	}
397
398	/// Set through table name
399	pub fn with_through(mut self, through: impl Into<String>) -> Self {
400		self.through = Some(through.into());
401		self
402	}
403
404	/// Set source field name
405	pub fn with_source_field(mut self, source_field: impl Into<String>) -> Self {
406		self.source_field = Some(source_field.into());
407		self
408	}
409
410	/// Set target field name
411	pub fn with_target_field(mut self, target_field: impl Into<String>) -> Self {
412		self.target_field = Some(target_field.into());
413		self
414	}
415
416	/// Set database constraint prefix
417	pub fn with_db_constraint_prefix(mut self, prefix: impl Into<String>) -> Self {
418		self.db_constraint_prefix = Some(prefix.into());
419		self
420	}
421}
422
423/// Global model registry
424///
425/// This registry is thread-safe and can be accessed from anywhere in the application.
426/// Models should register themselves during initialization, typically via derive macros.
427///
428/// # Django Equivalent
429/// ```python
430/// # Django: django/apps/registry.py
431/// class Apps:
432///     def __init__(self):
433///         self.all_models = defaultdict(dict)  # {app_label: {model_name: model_class}}
434///
435///     def register_model(self, app_label, model):
436///         model_name = model._meta.model_name
437///         self.all_models[app_label][model_name] = model
438///
439///     def get_models(self, include_auto_created=False, include_swapped=False):
440///         result = []
441///         for app_config in self.app_configs.values():
442///             result.extend(app_config.get_models(include_auto_created, include_swapped))
443///         return result
444/// ```
445#[derive(Debug, Clone)]
446pub struct ModelRegistry {
447	/// Models: (app_label, model_name) -> ModelMetadata
448	models: Arc<RwLock<HashMap<(String, String), ModelMetadata>>>,
449}
450
451impl ModelRegistry {
452	/// Creates a new instance.
453	pub fn new() -> Self {
454		Self {
455			models: Arc::new(RwLock::new(HashMap::new())),
456		}
457	}
458
459	/// Register a model in the registry
460	///
461	/// # Django Reference
462	/// From: django/apps/registry.py:215-240
463	/// ```python
464	/// def register_model(self, app_label, model):
465	///     model_name = model._meta.model_name
466	///     app_models = self.all_models[app_label]
467	///     if model_name in app_models:
468	///         # Handle conflicts...
469	///     app_models[model_name] = model
470	/// ```
471	pub fn register_model(&self, metadata: ModelMetadata) {
472		let key = (metadata.app_label.clone(), metadata.model_name.clone());
473		if let Ok(mut models) = self.models.write() {
474			models.insert(key, metadata);
475		}
476	}
477
478	/// Get all registered models
479	///
480	/// Returns a freshly-cloned `Vec<ModelMetadata>`. For hot paths that
481	/// only need to look up a single model, prefer
482	/// [`Self::find_model_qualified`] (when the target app is known) or
483	/// [`Self::find_model_by_name`] (when only the model name is known)
484	/// to avoid materializing the entire registry on each call.
485	///
486	/// # Django Reference
487	/// From: django/apps/registry.py:169-186
488	/// ```python
489	/// def get_models(self, include_auto_created=False, include_swapped=False):
490	///     result = []
491	///     for app_config in self.app_configs.values():
492	///         result.extend(app_config.get_models(include_auto_created, include_swapped))
493	///     return result
494	/// ```
495	pub fn get_models(&self) -> Vec<ModelMetadata> {
496		if let Ok(models) = self.models.read() {
497			models.values().cloned().collect()
498		} else {
499			Vec::new()
500		}
501	}
502
503	/// Get a specific model by app_label and model_name
504	///
505	/// # Django Reference
506	/// From: django/apps/registry.py:188-213
507	/// ```python
508	/// def get_model(self, app_label, model_name=None, require_ready=True):
509	///     if model_name is None:
510	///         app_label, model_name = app_label.split(".")
511	///     app_config = self.get_app_config(app_label)
512	///     return app_config.get_model(model_name, require_ready=require_ready)
513	/// ```
514	pub fn get_model(&self, app_label: &str, model_name: &str) -> Option<ModelMetadata> {
515		if let Ok(models) = self.models.read() {
516			models
517				.get(&(app_label.to_string(), model_name.to_string()))
518				.cloned()
519		} else {
520			None
521		}
522	}
523
524	/// Find a model by `(app_label, model_name)` without materializing the
525	/// entire registry.
526	///
527	/// The cost is an O(1) index lookup plus a single clone of the matched
528	/// [`ModelMetadata`] (whose size depends on its `fields` vector). This
529	/// is the preferred path for hot code (e.g. migration generation, FK
530	/// column type resolution) where [`Self::get_models`] would otherwise
531	/// clone every registered model on every call.
532	///
533	/// Semantically equivalent to [`Self::get_model`]; named to make the
534	/// "qualified lookup" intent explicit at call sites. See issue #4436.
535	pub fn find_model_qualified(&self, app_label: &str, model_name: &str) -> Option<ModelMetadata> {
536		self.get_model(app_label, model_name)
537	}
538
539	/// Find a model by `model_name` alone, without an app label.
540	///
541	/// Scans the registry values under the read lock but clones only the
542	/// matched entry (not the entire registry), so it avoids the
543	/// `Vec<ModelMetadata>` materialization in [`Self::get_models`].
544	///
545	/// # Ambiguity
546	///
547	/// If two or more apps have registered a model with the same
548	/// `model_name`, this function returns `None` and emits a
549	/// `tracing::warn!` (one log line per call — there is no
550	/// deduplication, so callers on a hot path should switch to
551	/// [`Self::find_model_qualified`]). Callers that need a specific
552	/// cross-app FK target must use [`Self::find_model_qualified`]
553	/// instead. This conservative behavior prevents the silent
554	/// wrong-target resolution flagged on PR #4434 (Copilot review
555	/// thread HYL).
556	///
557	/// See issue #4436.
558	pub fn find_model_by_name(&self, model_name: &str) -> Option<ModelMetadata> {
559		let models = self.models.read().ok()?;
560		let mut matches = models.values().filter(|m| m.model_name == model_name);
561		let first = matches.next()?.clone();
562		if matches.next().is_some() {
563			tracing::warn!(
564				model_name,
565				"ModelRegistry::find_model_by_name: ambiguous model name registered \
566				 under multiple app labels; returning None. Use \
567				 ModelRegistry::find_model_qualified(app, name) to disambiguate.",
568			);
569			return None;
570		}
571		Some(first)
572	}
573
574	/// Count how many registered models have `model_name`, irrespective
575	/// of app label.
576	///
577	/// Used by [`crate::migrations::operations`] FK column-type
578	/// resolution to distinguish "model name is genuinely missing" from
579	/// "model name is registered under more than one app" when a
580	/// by-name lookup returns `None`. The two cases need different
581	/// diagnostics: ambiguity is a user error worth a `tracing::warn!`,
582	/// while a missing name is normal during partial registry
583	/// population at startup.
584	///
585	/// See issue #4436.
586	pub fn count_models_by_name(&self, model_name: &str) -> usize {
587		if let Ok(models) = self.models.read() {
588			models
589				.values()
590				.filter(|m| m.model_name == model_name)
591				.count()
592		} else {
593			0
594		}
595	}
596
597	/// Get all models for a specific app
598	pub fn get_app_models(&self, app_label: &str) -> Vec<ModelMetadata> {
599		if let Ok(models) = self.models.read() {
600			models
601				.iter()
602				.filter(|((app, _), _)| app == app_label)
603				.map(|(_, meta)| meta.clone())
604				.collect()
605		} else {
606			Vec::new()
607		}
608	}
609
610	/// Remove a model from the registry
611	pub fn remove_model(&self, app_label: &str, model_name: &str) -> bool {
612		if let Ok(mut models) = self.models.write() {
613			models
614				.remove(&(app_label.to_string(), model_name.to_string()))
615				.is_some()
616		} else {
617			false
618		}
619	}
620
621	/// Clear all registered models
622	pub fn clear(&self) {
623		if let Ok(mut models) = self.models.write() {
624			models.clear();
625		}
626	}
627
628	/// Get the count of registered models
629	pub fn count(&self) -> usize {
630		if let Ok(models) = self.models.read() {
631			models.len()
632		} else {
633			0
634		}
635	}
636}
637
638impl Default for ModelRegistry {
639	fn default() -> Self {
640		Self::new()
641	}
642}
643
644/// Global model registry instance
645///
646/// This is the primary way to access the model registry from anywhere in the application.
647pub fn global_registry() -> &'static ModelRegistry {
648	use once_cell::sync::Lazy;
649	static REGISTRY: Lazy<ModelRegistry> = Lazy::new(ModelRegistry::new);
650	&REGISTRY
651}
652
653#[cfg(test)]
654mod tests {
655	use super::*;
656	use crate::migrations::FieldType;
657	use rstest::rstest;
658
659	#[test]
660	fn test_model_registry_new() {
661		let registry = ModelRegistry::new();
662		assert_eq!(registry.count(), 0);
663	}
664
665	#[test]
666	fn test_register_model() {
667		let registry = ModelRegistry::new();
668		let metadata = ModelMetadata::new("blog", "Post", "blog_post");
669		registry.register_model(metadata);
670		assert_eq!(registry.count(), 1);
671	}
672
673	#[test]
674	fn test_get_model() {
675		let registry = ModelRegistry::new();
676		let metadata = ModelMetadata::new("auth", "User", "auth_user");
677		registry.register_model(metadata);
678
679		let retrieved = registry.get_model("auth", "User");
680		assert!(retrieved.is_some());
681		assert_eq!(retrieved.unwrap().table_name, "auth_user");
682	}
683
684	#[test]
685	fn test_get_models() {
686		let registry = ModelRegistry::new();
687		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
688		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
689
690		let models = registry.get_models();
691		assert_eq!(models.len(), 2);
692	}
693
694	#[test]
695	fn test_find_model_qualified_hit() {
696		// Arrange
697		let registry = ModelRegistry::new();
698		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
699		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
700
701		// Act
702		let hit = registry.find_model_qualified("auth", "User");
703
704		// Assert
705		assert!(hit.is_some());
706		let model = hit.unwrap();
707		assert_eq!(model.app_label, "auth");
708		assert_eq!(model.model_name, "User");
709		assert_eq!(model.table_name, "auth_user");
710	}
711
712	#[test]
713	fn test_find_model_qualified_miss_wrong_app() {
714		// Arrange
715		let registry = ModelRegistry::new();
716		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
717
718		// Act / Assert: same model name registered under a different app
719		// must not be returned.
720		assert!(registry.find_model_qualified("billing", "User").is_none());
721	}
722
723	#[test]
724	fn test_find_model_by_name_unique() {
725		// Arrange
726		let registry = ModelRegistry::new();
727		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
728		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
729
730		// Act
731		let hit = registry.find_model_by_name("Post");
732
733		// Assert
734		assert!(hit.is_some());
735		assert_eq!(hit.unwrap().app_label, "blog");
736	}
737
738	#[test]
739	fn test_find_model_by_name_missing() {
740		// Arrange
741		let registry = ModelRegistry::new();
742		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
743
744		// Act / Assert
745		assert!(registry.find_model_by_name("NoSuchModel").is_none());
746	}
747
748	#[test]
749	fn test_find_model_by_name_ambiguous_returns_none() {
750		// Arrange: same model name registered under two different apps.
751		// The conservative behavior is to refuse the unqualified lookup
752		// rather than silently pick one (issue #4436, PR #4434 thread HYL).
753		let registry = ModelRegistry::new();
754		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
755		registry.register_model(ModelMetadata::new("billing", "User", "billing_user"));
756
757		// Act
758		let hit = registry.find_model_by_name("User");
759
760		// Assert
761		assert!(hit.is_none());
762	}
763
764	#[test]
765	fn test_get_app_models() {
766		let registry = ModelRegistry::new();
767		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
768		registry.register_model(ModelMetadata::new("auth", "Group", "auth_group"));
769		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
770
771		let auth_models = registry.get_app_models("auth");
772		assert_eq!(auth_models.len(), 2);
773
774		let blog_models = registry.get_app_models("blog");
775		assert_eq!(blog_models.len(), 1);
776	}
777
778	#[test]
779	fn test_remove_model() {
780		let registry = ModelRegistry::new();
781		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
782
783		assert!(registry.remove_model("auth", "User"));
784		assert_eq!(registry.count(), 0);
785	}
786
787	#[test]
788	fn test_migrations_registry_clear() {
789		let registry = ModelRegistry::new();
790		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
791		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
792
793		registry.clear();
794		assert_eq!(registry.count(), 0);
795	}
796
797	#[test]
798	fn test_model_metadata_to_model_state() {
799		let mut metadata = ModelMetadata::new("blog", "Post", "blog_post");
800
801		let mut title_field = FieldMetadata::new(FieldType::Custom("CharField".to_string()));
802		title_field
803			.params
804			.insert("max_length".to_string(), "200".to_string());
805		metadata.add_field("title".to_string(), title_field);
806
807		let model_state = metadata.to_model_state();
808		assert_eq!(model_state.name, "Post");
809		assert_eq!(model_state.fields.len(), 1);
810		assert!(model_state.fields.contains_key("title"));
811	}
812
813	#[test]
814	fn test_field_metadata_builder() {
815		let field = FieldMetadata::new(FieldType::Custom("CharField".to_string()))
816			.with_param("max_length", "100")
817			.with_nullable(false);
818
819		assert_eq!(field.field_type, FieldType::Custom("CharField".to_string()));
820		assert_eq!(field.params.get("max_length").unwrap(), "100");
821		assert!(!field.nullable);
822		assert_eq!(field.params.get("null").unwrap(), "false");
823
824		let field =
825			FieldMetadata::new(FieldType::Custom("IntegerField".to_string())).with_nullable(true);
826		assert!(field.nullable);
827		assert_eq!(field.params.get("null").unwrap(), "true");
828	}
829
830	#[rstest]
831	#[case(true, true)]
832	#[case(false, false)]
833	fn test_to_model_state_overrides_nullable_from_params(
834		#[case] nullable: bool,
835		#[case] expected_nullable: bool,
836	) {
837		// Arrange
838		let mut metadata = ModelMetadata::new("blog", "Post", "blog_post");
839		let field = FieldMetadata::new(FieldType::Custom("CharField".to_string()))
840			.with_param("max_length", "200")
841			.with_nullable(nullable);
842		metadata.add_field("description".to_string(), field);
843
844		// Act
845		let model_state = metadata.to_model_state();
846
847		// Assert
848		let field_state = model_state.fields.get("description").unwrap();
849		assert_eq!(field_state.nullable, expected_nullable);
850		assert!(
851			!field_state.params.contains_key("null"),
852			"params must not contain `null` key after to_model_state 			 (it is already carried by FieldState.nullable)"
853		);
854	}
855
856	#[rstest]
857	fn to_model_state_nullable_false_for_primary_key_matches_macro_contract() {
858		// Arrange — regression for issue #4052.
859		//
860		// The `#[model]` macro must emit `null = "false"` for primary key
861		// fields regardless of whether the Rust type is `Option<T>`. The
862		// `Option<T>` wrapper for PKs is a Rust-side convention to allow
863		// `id = None` before the DB assigns the auto-increment value, not
864		// a DB-level nullability statement. PK columns are always NOT NULL
865		// at the DB level.
866		//
867		// This test codifies the contract that `to_model_state` consumes
868		// from the macro: with the fixed macro params, the resulting
869		// `FieldState.nullable` for an `Option<i64>` PK must be `false`,
870		// matching the migration-replay path's
871		// `column_def_to_field_state(...).nullable = !col.not_null = false`.
872		//
873		// Pre-fix, the macro emitted `null = "true"` for any Option<T>
874		// field including PKs, producing `FieldState.nullable = true` and
875		// surfacing as a spurious `AlterColumn` for the unchanged PK in
876		// offline `makemigrations` runs.
877		let mut metadata = ModelMetadata::new("clusters", "Cluster", "clusters");
878		// Mirror the fixed macro params for `id: Option<i64>` with
879		// `#[field(primary_key = true)]`: `null = "false"` (forced by the
880		// fix), `not_null = "true"`, `primary_key = "true"`,
881		// `auto_increment = "true"`.
882		let id_field = FieldMetadata::new(FieldType::BigInteger)
883			.with_param("primary_key", "true")
884			.with_param("auto_increment", "true")
885			.with_param("not_null", "true")
886			.with_nullable(false);
887		metadata.add_field("id".to_string(), id_field);
888
889		// Act
890		let model_state = metadata.to_model_state();
891
892		// Assert — nullable=false on the FieldState side, regardless of
893		// the underlying Rust Option<T> wrapping.
894		let id_state = model_state
895			.fields
896			.get("id")
897			.expect("id field present in to_model_state output");
898		assert!(
899			!id_state.nullable,
900			"PK FieldState.nullable must be false even when the Rust type is \
901			 Option<i64>. Did the #[model] macro regress to emitting \
902			 null=\"true\" for Option<T> PKs? params={:?}",
903			id_state.params
904		);
905		assert!(
906			!id_state.params.contains_key("null"),
907			"PK params must not contain `null` after to_model_state \
908			 (nullable is already carried by FieldState.nullable). \
909			 Got params={:?}",
910			id_state.params
911		);
912	}
913}