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