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::{
16	FieldState, IndexDefinition, ModelState, default_index_name, index_definitions_equivalent,
17};
18use std::collections::{HashMap, HashSet};
19use std::sync::{Arc, RwLock};
20
21#[cfg_attr(doc, aquamarine::aquamarine)]
22/// Model metadata for registration
23///
24/// # Architecture
25///
26/// This struct mirrors Django's model registration pattern:
27///
28/// ```mermaid
29/// graph LR
30///     subgraph Django["Django (Reference)"]
31///         Apps["Apps"]
32///         Apps --> all_models["all_models"]
33///         Apps --> register_model["register_model()"]
34///         Apps --> get_models["get_models()"]
35///     end
36///
37///     subgraph Reinhardt["Reinhardt"]
38///         ModelRegistry["ModelRegistry"]
39///         ModelRegistry --> models["models"]
40///         ModelRegistry --> register_model2["register_model()"]
41///         ModelRegistry --> get_models2["get_models()"]
42///         ModelRegistry --> get_model["get_model()"]
43///     end
44///
45///     Django -.-> Reinhardt
46/// ```
47#[derive(Debug, Clone)]
48pub struct ModelMetadata {
49	/// Application label (e.g., "auth", "blog")
50	pub app_label: String,
51	/// Model name (e.g., "User", "Post")
52	pub model_name: String,
53	/// Table name (e.g., "auth_user", "blog_post")
54	pub table_name: String,
55	/// Field definitions
56	pub fields: HashMap<String, FieldMetadata>,
57	/// Model options (e.g., db_table, ordering)
58	pub options: HashMap<String, String>,
59	/// ManyToMany relationship definitions
60	pub many_to_many_fields: Vec<ManyToManyMetadata>,
61	/// Model-level constraints declared via `#[model(unique_together = ...)]`
62	/// and other peer-constraint attributes. Field-level `unique = true` is
63	/// still synthesized inside `to_model_state()` and not stored here, to
64	/// preserve the existing single-field UNIQUE behavior.
65	///
66	/// Kept private so that adding the field to a previously
67	/// externally-constructible struct does not break the public API.
68	/// Read via [`Self::constraints`]; write via [`Self::add_constraint`].
69	constraints: Vec<ConstraintDefinition>,
70	/// Model-level index definitions declared via model metadata.
71	///
72	/// Kept private so that adding the field to a previously
73	/// externally-constructible struct does not break the public API.
74	/// Read via [`Self::indexes`]; write via [`Self::add_index`].
75	indexes: Vec<IndexDefinition>,
76}
77
78impl ModelMetadata {
79	const MAX_CONSTRAINT_IDENTIFIER_BYTES: usize = 63;
80
81	/// Creates a new instance.
82	pub fn new(
83		app_label: impl Into<String>,
84		model_name: impl Into<String>,
85		table_name: impl Into<String>,
86	) -> Self {
87		Self {
88			app_label: app_label.into(),
89			model_name: model_name.into(),
90			table_name: table_name.into(),
91			fields: HashMap::new(),
92			options: HashMap::new(),
93			many_to_many_fields: Vec::new(),
94			constraints: Vec::new(),
95			indexes: Vec::new(),
96		}
97	}
98
99	/// Adds field.
100	pub fn add_field(&mut self, name: String, field: FieldMetadata) {
101		self.fields.insert(name, field);
102	}
103
104	/// Sets the option.
105	pub fn set_option(&mut self, key: String, value: String) {
106		self.options.insert(key, value);
107	}
108
109	/// Adds many to many.
110	pub fn add_many_to_many(&mut self, m2m: ManyToManyMetadata) {
111		self.many_to_many_fields.push(m2m);
112	}
113
114	/// Adds a model-level constraint declared via macro attributes
115	/// (e.g., `#[model(unique_together = ...)]`).
116	pub fn add_constraint(&mut self, constraint: ConstraintDefinition) {
117		self.constraints.push(constraint);
118	}
119
120	fn synthesized_unique_constraint_name(
121		&self,
122		field_name: &str,
123		generated_names: &HashSet<String>,
124		existing_constraints: &[ConstraintDefinition],
125	) -> String {
126		// The raw tuple digest is required because concatenated safe fragments do
127		// not preserve table/field boundaries and normalized field names can
128		// collide. It also makes the name independent of field iteration order.
129		let tuple_digest =
130			stable_constraint_name_hash(&format!("{}\0{}", self.table_name, field_name));
131		let base_name = bounded_constraint_identifier(&format!(
132			"{}_{}_uniq_{tuple_digest:08x}",
133			safe_constraint_table_fragment(&self.table_name),
134			safe_constraint_name_fragment(field_name)
135		));
136		let is_taken = |candidate: &str| {
137			self.constraints
138				.iter()
139				.any(|constraint| constraint.name.eq_ignore_ascii_case(candidate))
140				|| existing_constraints
141					.iter()
142					.any(|constraint| constraint.name.eq_ignore_ascii_case(candidate))
143				|| generated_names
144					.iter()
145					.any(|name| name.eq_ignore_ascii_case(candidate))
146		};
147		if !is_taken(&base_name) {
148			return base_name;
149		}
150
151		let field_digest = stable_constraint_name_hash(field_name);
152		let mut candidate =
153			bounded_constraint_identifier(&format!("{base_name}_field_{field_digest:08x}"));
154		let mut suffix = 2;
155		while is_taken(&candidate) {
156			candidate = bounded_constraint_identifier(&format!(
157				"{base_name}_field_{field_digest:08x}_{suffix}"
158			));
159			suffix += 1;
160		}
161		candidate
162	}
163
164	/// Returns constraints registered by the `#[model(...)]` macro, such as
165	/// composite UNIQUE constraints and field-level CHECK constraints.
166	///
167	/// Field-level `unique = true` is not included here; it is synthesized
168	/// inside [`Self::to_model_state`] from `FieldMetadata` parameters.
169	pub fn constraints(&self) -> &[ConstraintDefinition] {
170		&self.constraints
171	}
172
173	/// Adds a model-level index declared by the model macro or caller.
174	pub fn add_index(&mut self, index: IndexDefinition) {
175		self.indexes.push(index);
176	}
177
178	/// Returns model-level indexes registered by the model macro or caller.
179	pub fn indexes(&self) -> &[IndexDefinition] {
180		&self.indexes
181	}
182
183	/// Convert to ModelState for migrations
184	///
185	/// # Examples
186	///
187	/// ```rust,ignore
188	/// use reinhardt_db::migrations::model_registry::{ModelMetadata, FieldMetadata};
189	/// use reinhardt_db::migrations::FieldType;
190	///
191	/// let mut metadata = ModelMetadata::new("myapp", "User", "myapp_user");
192	/// metadata.add_field(
193	///     "email".to_string(),
194	///     FieldMetadata::new(FieldType::VarChar(255)).with_param("max_length", "255"),
195	/// );
196	///
197	/// let model_state = metadata.to_model_state();
198	/// assert_eq!(model_state.app_label, "myapp");
199	/// assert_eq!(model_state.name, "User");
200	/// assert!(model_state.has_field("email"));
201	/// ```
202	pub fn to_model_state(&self) -> ModelState {
203		let mut model_state = ModelState::new(&self.app_label, &self.model_name);
204
205		// Set the correct table name from metadata
206		// This overrides the default snake_case conversion in ModelState::new
207		model_state.table_name = self.table_name.clone();
208
209		// Convert fields
210		for (name, field_meta) in &self.fields {
211			let is_unique = field_meta.params.get("unique").map(String::as_str) == Some("true");
212			let mut field_state = FieldState::new(
213				name.clone(),
214				field_meta.field_type.clone(),
215				field_meta.nullable,
216			);
217			for (key, value) in &field_meta.params {
218				if key == "null" || (is_unique && key == "unique") {
219					continue;
220				}
221				field_state.params.insert(key.clone(), value.clone());
222			}
223			// Set ForeignKey information if present
224			if let Some(ref fk_info) = field_meta.foreign_key {
225				field_state.foreign_key = Some(fk_info.clone());
226			}
227			model_state.add_field(field_state);
228		}
229
230		// Copy options
231		model_state.options = self.options.clone();
232
233		// Generate ForeignKey constraints from fields
234		for (field_name, field_meta) in &self.fields {
235			if field_meta.foreign_key.is_some() {
236				model_state.add_foreign_key_constraint_from_field(field_name);
237			}
238		}
239
240		// Copy ManyToMany relationship metadata
241		model_state.many_to_many_fields = self.many_to_many_fields.clone();
242
243		// Copy explicitly declared indexes before synthesizing default indexes so
244		// an equivalent explicit index is not duplicated.
245		model_state.indexes.extend(self.indexes.iter().cloned());
246
247		// Foreign-key ID fields carry db_index=true by default. Materialize that
248		// metadata as a non-unique index unless the field is already unique.
249		let mut synthesized_indexes = self
250			.fields
251			.iter()
252			.filter_map(|(field_name, field_meta)| {
253				let has_default_index =
254					field_meta.params.get("db_index").map(String::as_str) == Some("true");
255				let is_unique = field_meta.params.get("unique").map(String::as_str) == Some("true")
256					|| field_meta.params.get("primary_key").map(String::as_str) == Some("true");
257				if !has_default_index || is_unique {
258					return None;
259				}
260
261				Some(IndexDefinition {
262					name: default_index_name(&self.table_name, std::slice::from_ref(field_name)),
263					fields: vec![field_name.clone()],
264					unique: false,
265					where_clause: None,
266					index_type: None,
267					expressions: None,
268					concurrently: false,
269					mysql_options: None,
270					operator_class: None,
271				})
272			})
273			.collect::<Vec<_>>();
274		synthesized_indexes.sort_by(|left, right| left.name.cmp(&right.name));
275		for index in synthesized_indexes {
276			if !model_state
277				.indexes
278				.iter()
279				.any(|existing| index_definitions_equivalent(existing, &index))
280			{
281				model_state.indexes.push(index);
282			}
283		}
284
285		// Generate named Unique constraints from field params. The field-level
286		// `unique` flag is consumed above so the same declaration cannot be
287		// emitted both inline and as a table constraint.
288		let mut generated_unique_constraint_names = HashSet::new();
289		let mut unique_fields = self
290			.fields
291			.iter()
292			.filter(|(_, field_meta)| {
293				field_meta.params.get("unique").map(String::as_str) == Some("true")
294			})
295			.collect::<Vec<_>>();
296		unique_fields.sort_unstable_by_key(|(left, _)| *left);
297		for (field_name, field_meta) in unique_fields {
298			if field_meta.params.get("unique").map(String::as_str) == Some("true") {
299				// Prefer a model-level declaration when it explicitly names the
300				// single-column constraint. This keeps one physical constraint and
301				// preserves the declared name.
302				if self.constraints.iter().any(|constraint| {
303					constraint.constraint_type.eq_ignore_ascii_case("unique")
304						&& constraint.fields.len() == 1
305						&& constraint.fields[0] == *field_name
306				}) {
307					continue;
308				}
309				let constraint = ConstraintDefinition {
310					name: self.synthesized_unique_constraint_name(
311						field_name,
312						&generated_unique_constraint_names,
313						&model_state.constraints,
314					),
315					constraint_type: "unique".to_string(),
316					fields: vec![field_name.clone()],
317					expression: None,
318					foreign_key_info: None,
319				};
320				generated_unique_constraint_names.insert(constraint.name.clone());
321				model_state.constraints.push(constraint);
322			}
323		}
324
325		// Copy model-level constraints declared via #[model(unique_together = ...)]
326		// (and other peer-constraint attributes). These are populated by the
327		// derive macro at registration time. See reinhardt-web#4022.
328		model_state
329			.constraints
330			.extend(self.constraints.iter().cloned());
331
332		model_state
333	}
334}
335
336fn safe_constraint_name_fragment(value: &str) -> String {
337	let mut fragment = String::with_capacity(value.len());
338	for character in value.chars() {
339		if character.is_ascii_alphanumeric() || character == '_' {
340			fragment.push(character.to_ascii_lowercase());
341		} else {
342			fragment.push('_');
343		}
344	}
345
346	if fragment.is_empty() {
347		fragment.push_str("table");
348	} else if fragment
349		.as_bytes()
350		.first()
351		.is_some_and(|character| character.is_ascii_digit())
352	{
353		fragment.insert_str(0, "table_");
354	}
355	fragment
356}
357
358fn safe_constraint_table_fragment(value: &str) -> String {
359	let fragment = safe_constraint_name_fragment(value);
360	if fragment == value {
361		return fragment;
362	}
363	format!("{fragment}_{:08x}", stable_constraint_name_hash(value))
364}
365
366fn stable_constraint_name_hash(value: &str) -> u32 {
367	let mut hash = 0x811c9dc5_u32;
368	for byte in value.bytes() {
369		hash ^= u32::from(byte);
370		hash = hash.wrapping_mul(0x01000193);
371	}
372	hash
373}
374
375fn bounded_constraint_identifier(value: &str) -> String {
376	if value.len() <= ModelMetadata::MAX_CONSTRAINT_IDENTIFIER_BYTES {
377		return value.to_owned();
378	}
379
380	let suffix = format!("_{:08x}", stable_constraint_name_hash(value));
381	let prefix_len = ModelMetadata::MAX_CONSTRAINT_IDENTIFIER_BYTES - suffix.len();
382	let mut end = prefix_len;
383	while !value.is_char_boundary(end) {
384		end -= 1;
385	}
386	format!("{}{}", &value[..end], suffix)
387}
388
389/// Field metadata for registration
390#[derive(Debug, Clone)]
391pub struct FieldMetadata {
392	/// Field type (e.g., CharField, IntegerField, ForeignKey)
393	pub field_type: super::FieldType,
394	/// Whether this field is nullable (`NULL` is allowed).
395	///
396	/// This is the canonical source of truth. [`Self::is_nullable`]
397	/// returns this value directly. [`Self::with_nullable`] sets both
398	/// this field and syncs `params["null"]` for backward compatibility.
399	pub nullable: bool,
400	/// Field parameters (max_length, blank, default, etc.)
401	pub params: HashMap<String, String>,
402	/// ForeignKey information if this field is a foreign key
403	pub foreign_key: Option<super::autodetector::ForeignKeyInfo>,
404}
405
406impl FieldMetadata {
407	/// Creates a new instance.
408	pub fn new(field_type: super::FieldType) -> Self {
409		Self {
410			field_type,
411			nullable: false,
412			params: HashMap::new(),
413			foreign_key: None,
414		}
415	}
416
417	/// Sets the param and returns self for chaining.
418	///
419	/// When `key` is `"null"`, the value is parsed as a bool and
420	/// [`Self::nullable`] is synced automatically to prevent silent
421	/// divergence between the struct field and `params["null"]`.
422	/// Prefer [`Self::with_nullable`] for new code.
423	pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
424		let key_s: String = key.into();
425		let value_s: String = value.into();
426		if key_s == "null" {
427			let parsed = value_s.parse::<bool>().unwrap_or(false);
428			self.nullable = parsed;
429			self.params.insert(key_s, parsed.to_string());
430			return self;
431		}
432		self.params.insert(key_s, value_s);
433		self
434	}
435
436	/// Sets the nullability and returns self for chaining.
437	///
438	/// Sets both [`Self::nullable`] (canonical) and `params["null"]`
439	/// (backward compatibility).
440	pub fn with_nullable(mut self, nullable: bool) -> Self {
441		self.nullable = nullable;
442		self.params.insert("null".to_string(), nullable.to_string());
443		self
444	}
445
446	/// Returns whether the column is nullable (i.e., `NULL` is allowed).
447	pub fn is_nullable(&self) -> bool {
448		self.nullable
449	}
450
451	/// Sets the foreign key and returns self for chaining.
452	pub fn with_foreign_key(mut self, foreign_key: super::autodetector::ForeignKeyInfo) -> Self {
453		self.foreign_key = Some(foreign_key);
454		self
455	}
456}
457
458/// Relationship metadata for `#[rel]` attributes
459///
460/// This structure holds metadata about relationships defined on model fields
461/// using the `#[rel(...)]` attribute.
462#[derive(Debug, Clone)]
463pub struct RelationshipMetadata {
464	/// Field name
465	pub field_name: String,
466	/// Relationship type (foreign_key, one_to_one, many_to_many, etc.)
467	pub rel_type: String,
468	/// Target model (e.g., "User", "auth.User")
469	pub to_model: Option<String>,
470	/// Related name for reverse accessor
471	pub related_name: Option<String>,
472	/// Through table name (for ManyToMany)
473	pub through_table: Option<String>,
474	/// Composite struct name (for additional through table fields)
475	pub composite: Option<String>,
476	/// Source model app label (for generating Through table foreign keys)
477	pub source_app_label: Option<String>,
478	/// Source model name (for generating Through table foreign keys)
479	pub source_model_name: Option<String>,
480}
481
482impl RelationshipMetadata {
483	/// Create a new RelationshipMetadata
484	pub fn new(field_name: impl Into<String>, rel_type: impl Into<String>) -> Self {
485		Self {
486			field_name: field_name.into(),
487			rel_type: rel_type.into(),
488			to_model: None,
489			related_name: None,
490			through_table: None,
491			composite: None,
492			source_app_label: None,
493			source_model_name: None,
494		}
495	}
496
497	/// Set target model
498	pub fn with_to_model(mut self, to_model: impl Into<String>) -> Self {
499		self.to_model = Some(to_model.into());
500		self
501	}
502
503	/// Set related name
504	pub fn with_related_name(mut self, related_name: impl Into<String>) -> Self {
505		self.related_name = Some(related_name.into());
506		self
507	}
508
509	/// Set through table name
510	pub fn with_through_table(mut self, through_table: impl Into<String>) -> Self {
511		self.through_table = Some(through_table.into());
512		self
513	}
514
515	/// Set composite struct name
516	pub fn with_composite(mut self, composite: impl Into<String>) -> Self {
517		self.composite = Some(composite.into());
518		self
519	}
520
521	/// Set source model information
522	pub fn with_source_info(
523		mut self,
524		app_label: impl Into<String>,
525		model_name: impl Into<String>,
526	) -> Self {
527		self.source_app_label = Some(app_label.into());
528		self.source_model_name = Some(model_name.into());
529		self
530	}
531
532	/// Check if this is a ManyToMany relationship
533	pub fn is_many_to_many(&self) -> bool {
534		self.rel_type == "many_to_many" || self.rel_type == "polymorphic_many_to_many"
535	}
536}
537
538/// ManyToMany relationship metadata
539///
540/// This structure holds specific metadata for ManyToMany relationships,
541/// including through table information and custom field names.
542#[derive(Debug, Clone, PartialEq)]
543pub struct ManyToManyMetadata {
544	/// Field name
545	pub field_name: String,
546	/// Target model name (e.g., "Group", "User")
547	pub to_model: String,
548	/// Related name for reverse accessor
549	pub related_name: Option<String>,
550	/// Custom through table name (if specified)
551	pub through: Option<String>,
552	/// Source field name in through table (defaults to "{source_model}_id")
553	pub source_field: Option<String>,
554	/// Target field name in through table (defaults to "{target_model}_id")
555	pub target_field: Option<String>,
556	/// Database constraint prefix
557	pub db_constraint_prefix: Option<String>,
558}
559
560impl ManyToManyMetadata {
561	/// Create a new ManyToManyMetadata
562	pub fn new(field_name: impl Into<String>, to_model: impl Into<String>) -> Self {
563		Self {
564			field_name: field_name.into(),
565			to_model: to_model.into(),
566			related_name: None,
567			through: None,
568			source_field: None,
569			target_field: None,
570			db_constraint_prefix: None,
571		}
572	}
573
574	/// Set related name
575	pub fn with_related_name(mut self, related_name: impl Into<String>) -> Self {
576		self.related_name = Some(related_name.into());
577		self
578	}
579
580	/// Set through table name
581	pub fn with_through(mut self, through: impl Into<String>) -> Self {
582		self.through = Some(through.into());
583		self
584	}
585
586	/// Set source field name
587	pub fn with_source_field(mut self, source_field: impl Into<String>) -> Self {
588		self.source_field = Some(source_field.into());
589		self
590	}
591
592	/// Set target field name
593	pub fn with_target_field(mut self, target_field: impl Into<String>) -> Self {
594		self.target_field = Some(target_field.into());
595		self
596	}
597
598	/// Set database constraint prefix
599	pub fn with_db_constraint_prefix(mut self, prefix: impl Into<String>) -> Self {
600		self.db_constraint_prefix = Some(prefix.into());
601		self
602	}
603}
604
605/// Global model registry
606///
607/// This registry is thread-safe and can be accessed from anywhere in the application.
608/// Models should register themselves during initialization, typically via derive macros.
609///
610/// # Django Equivalent
611/// ```python
612/// # Django: django/apps/registry.py
613/// class Apps:
614///     def __init__(self):
615///         self.all_models = defaultdict(dict)  # {app_label: {model_name: model_class}}
616///
617///     def register_model(self, app_label, model):
618///         model_name = model._meta.model_name
619///         self.all_models[app_label][model_name] = model
620///
621///     def get_models(self, include_auto_created=False, include_swapped=False):
622///         result = []
623///         for app_config in self.app_configs.values():
624///             result.extend(app_config.get_models(include_auto_created, include_swapped))
625///         return result
626/// ```
627#[derive(Debug, Clone)]
628pub struct ModelRegistry {
629	/// Models: (app_label, model_name) -> ModelMetadata
630	models: Arc<RwLock<HashMap<(String, String), ModelMetadata>>>,
631}
632
633impl ModelRegistry {
634	/// Creates a new instance.
635	pub fn new() -> Self {
636		Self {
637			models: Arc::new(RwLock::new(HashMap::new())),
638		}
639	}
640
641	/// Register a model in the registry
642	///
643	/// # Django Reference
644	/// From: django/apps/registry.py:215-240
645	/// ```python
646	/// def register_model(self, app_label, model):
647	///     model_name = model._meta.model_name
648	///     app_models = self.all_models[app_label]
649	///     if model_name in app_models:
650	///         # Handle conflicts...
651	///     app_models[model_name] = model
652	/// ```
653	pub fn register_model(&self, metadata: ModelMetadata) {
654		let key = (metadata.app_label.clone(), metadata.model_name.clone());
655		if let Ok(mut models) = self.models.write() {
656			models.insert(key, metadata);
657		}
658	}
659
660	/// Get all registered models
661	///
662	/// Returns a freshly-cloned `Vec<ModelMetadata>`. For hot paths that
663	/// only need to look up a single model, prefer
664	/// [`Self::find_model_qualified`] (when the target app is known) or
665	/// [`Self::find_model_by_name`] (when only the model name is known)
666	/// to avoid materializing the entire registry on each call.
667	///
668	/// # Django Reference
669	/// From: django/apps/registry.py:169-186
670	/// ```python
671	/// def get_models(self, include_auto_created=False, include_swapped=False):
672	///     result = []
673	///     for app_config in self.app_configs.values():
674	///         result.extend(app_config.get_models(include_auto_created, include_swapped))
675	///     return result
676	/// ```
677	pub fn get_models(&self) -> Vec<ModelMetadata> {
678		if let Ok(models) = self.models.read() {
679			models.values().cloned().collect()
680		} else {
681			Vec::new()
682		}
683	}
684
685	/// Get a specific model by app_label and model_name
686	///
687	/// # Django Reference
688	/// From: django/apps/registry.py:188-213
689	/// ```python
690	/// def get_model(self, app_label, model_name=None, require_ready=True):
691	///     if model_name is None:
692	///         app_label, model_name = app_label.split(".")
693	///     app_config = self.get_app_config(app_label)
694	///     return app_config.get_model(model_name, require_ready=require_ready)
695	/// ```
696	pub fn get_model(&self, app_label: &str, model_name: &str) -> Option<ModelMetadata> {
697		if let Ok(models) = self.models.read() {
698			models
699				.get(&(app_label.to_string(), model_name.to_string()))
700				.cloned()
701		} else {
702			None
703		}
704	}
705
706	/// Find a model by `(app_label, model_name)` without materializing the
707	/// entire registry.
708	///
709	/// The cost is an O(1) index lookup plus a single clone of the matched
710	/// [`ModelMetadata`] (whose size depends on its `fields` vector). This
711	/// is the preferred path for hot code (e.g. migration generation, FK
712	/// column type resolution) where [`Self::get_models`] would otherwise
713	/// clone every registered model on every call.
714	///
715	/// Semantically equivalent to [`Self::get_model`]; named to make the
716	/// "qualified lookup" intent explicit at call sites. See issue #4436.
717	pub fn find_model_qualified(&self, app_label: &str, model_name: &str) -> Option<ModelMetadata> {
718		self.get_model(app_label, model_name)
719	}
720
721	/// Find a model by `model_name` alone, without an app label.
722	///
723	/// Scans the registry values under the read lock but clones only the
724	/// matched entry (not the entire registry), so it avoids the
725	/// `Vec<ModelMetadata>` materialization in [`Self::get_models`].
726	///
727	/// # Ambiguity
728	///
729	/// If two or more apps have registered a model with the same
730	/// `model_name`, this function returns `None` and emits a
731	/// `tracing::warn!` (one log line per call — there is no
732	/// deduplication, so callers on a hot path should switch to
733	/// [`Self::find_model_qualified`]). Callers that need a specific
734	/// cross-app FK target must use [`Self::find_model_qualified`]
735	/// instead. This conservative behavior prevents the silent
736	/// wrong-target resolution flagged on PR #4434 (Copilot review
737	/// thread HYL).
738	///
739	/// See issue #4436.
740	pub fn find_model_by_name(&self, model_name: &str) -> Option<ModelMetadata> {
741		let models = self.models.read().ok()?;
742		let mut matches = models.values().filter(|m| m.model_name == model_name);
743		let first = matches.next()?.clone();
744		if matches.next().is_some() {
745			tracing::warn!(
746				model_name,
747				"ModelRegistry::find_model_by_name: ambiguous model name registered \
748				 under multiple app labels; returning None. Use \
749				 ModelRegistry::find_model_qualified(app, name) to disambiguate.",
750			);
751			return None;
752		}
753		Some(first)
754	}
755
756	/// Count how many registered models have `model_name`, irrespective
757	/// of app label.
758	///
759	/// Used by [`crate::migrations::operations`] FK column-type
760	/// resolution to distinguish "model name is genuinely missing" from
761	/// "model name is registered under more than one app" when a
762	/// by-name lookup returns `None`. The two cases need different
763	/// diagnostics: ambiguity is a user error worth a `tracing::warn!`,
764	/// while a missing name is normal during partial registry
765	/// population at startup.
766	///
767	/// See issue #4436.
768	pub fn count_models_by_name(&self, model_name: &str) -> usize {
769		if let Ok(models) = self.models.read() {
770			models
771				.values()
772				.filter(|m| m.model_name == model_name)
773				.count()
774		} else {
775			0
776		}
777	}
778
779	/// Get all models for a specific app
780	pub fn get_app_models(&self, app_label: &str) -> Vec<ModelMetadata> {
781		if let Ok(models) = self.models.read() {
782			models
783				.iter()
784				.filter(|((app, _), _)| app == app_label)
785				.map(|(_, meta)| meta.clone())
786				.collect()
787		} else {
788			Vec::new()
789		}
790	}
791
792	/// Remove a model from the registry
793	pub fn remove_model(&self, app_label: &str, model_name: &str) -> bool {
794		if let Ok(mut models) = self.models.write() {
795			models
796				.remove(&(app_label.to_string(), model_name.to_string()))
797				.is_some()
798		} else {
799			false
800		}
801	}
802
803	/// Clear all registered models
804	pub fn clear(&self) {
805		if let Ok(mut models) = self.models.write() {
806			models.clear();
807		}
808	}
809
810	/// Get the count of registered models
811	pub fn count(&self) -> usize {
812		if let Ok(models) = self.models.read() {
813			models.len()
814		} else {
815			0
816		}
817	}
818}
819
820impl Default for ModelRegistry {
821	fn default() -> Self {
822		Self::new()
823	}
824}
825
826/// Global model registry instance
827///
828/// This is the primary way to access the model registry from anywhere in the application.
829pub fn global_registry() -> &'static ModelRegistry {
830	use once_cell::sync::Lazy;
831	static REGISTRY: Lazy<ModelRegistry> = Lazy::new(ModelRegistry::new);
832	&REGISTRY
833}
834
835#[cfg(test)]
836mod tests {
837	use super::*;
838	use crate::migrations::FieldType;
839	use crate::migrations::autodetector::{ForeignKeyInfo, MigrationAutodetector, ProjectState};
840	use crate::migrations::operations::{Constraint, Operation, SqlDialect};
841	use rstest::rstest;
842
843	#[test]
844	fn test_model_registry_new() {
845		let registry = ModelRegistry::new();
846		assert_eq!(registry.count(), 0);
847	}
848
849	#[test]
850	fn test_register_model() {
851		let registry = ModelRegistry::new();
852		let metadata = ModelMetadata::new("blog", "Post", "blog_post");
853		registry.register_model(metadata);
854		assert_eq!(registry.count(), 1);
855	}
856
857	#[test]
858	fn test_get_model() {
859		let registry = ModelRegistry::new();
860		let metadata = ModelMetadata::new("auth", "User", "auth_user");
861		registry.register_model(metadata);
862
863		let retrieved = registry.get_model("auth", "User");
864		assert!(retrieved.is_some());
865		assert_eq!(retrieved.unwrap().table_name, "auth_user");
866	}
867
868	#[test]
869	fn test_get_models() {
870		let registry = ModelRegistry::new();
871		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
872		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
873
874		let models = registry.get_models();
875		assert_eq!(models.len(), 2);
876	}
877
878	#[test]
879	fn test_find_model_qualified_hit() {
880		// Arrange
881		let registry = ModelRegistry::new();
882		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
883		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
884
885		// Act
886		let hit = registry.find_model_qualified("auth", "User");
887
888		// Assert
889		assert!(hit.is_some());
890		let model = hit.unwrap();
891		assert_eq!(model.app_label, "auth");
892		assert_eq!(model.model_name, "User");
893		assert_eq!(model.table_name, "auth_user");
894	}
895
896	#[test]
897	fn test_find_model_qualified_miss_wrong_app() {
898		// Arrange
899		let registry = ModelRegistry::new();
900		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
901
902		// Act / Assert: same model name registered under a different app
903		// must not be returned.
904		assert!(registry.find_model_qualified("billing", "User").is_none());
905	}
906
907	#[test]
908	fn test_find_model_by_name_unique() {
909		// Arrange
910		let registry = ModelRegistry::new();
911		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
912		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
913
914		// Act
915		let hit = registry.find_model_by_name("Post");
916
917		// Assert
918		assert!(hit.is_some());
919		assert_eq!(hit.unwrap().app_label, "blog");
920	}
921
922	#[test]
923	fn test_find_model_by_name_missing() {
924		// Arrange
925		let registry = ModelRegistry::new();
926		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
927
928		// Act / Assert
929		assert!(registry.find_model_by_name("NoSuchModel").is_none());
930	}
931
932	#[test]
933	fn test_find_model_by_name_ambiguous_returns_none() {
934		// Arrange: same model name registered under two different apps.
935		// The conservative behavior is to refuse the unqualified lookup
936		// rather than silently pick one (issue #4436, PR #4434 thread HYL).
937		let registry = ModelRegistry::new();
938		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
939		registry.register_model(ModelMetadata::new("billing", "User", "billing_user"));
940
941		// Act
942		let hit = registry.find_model_by_name("User");
943
944		// Assert
945		assert!(hit.is_none());
946	}
947
948	#[test]
949	fn test_get_app_models() {
950		let registry = ModelRegistry::new();
951		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
952		registry.register_model(ModelMetadata::new("auth", "Group", "auth_group"));
953		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
954
955		let auth_models = registry.get_app_models("auth");
956		assert_eq!(auth_models.len(), 2);
957
958		let blog_models = registry.get_app_models("blog");
959		assert_eq!(blog_models.len(), 1);
960	}
961
962	#[test]
963	fn test_remove_model() {
964		let registry = ModelRegistry::new();
965		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
966
967		assert!(registry.remove_model("auth", "User"));
968		assert_eq!(registry.count(), 0);
969	}
970
971	#[test]
972	fn test_migrations_registry_clear() {
973		let registry = ModelRegistry::new();
974		registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
975		registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
976
977		registry.clear();
978		assert_eq!(registry.count(), 0);
979	}
980
981	#[test]
982	fn test_model_metadata_to_model_state() {
983		let mut metadata = ModelMetadata::new("blog", "Post", "blog_post");
984
985		let mut title_field = FieldMetadata::new(FieldType::Custom("CharField".to_string()));
986		title_field
987			.params
988			.insert("max_length".to_string(), "200".to_string());
989		metadata.add_field("title".to_string(), title_field);
990
991		let model_state = metadata.to_model_state();
992		assert_eq!(model_state.name, "Post");
993		assert_eq!(model_state.fields.len(), 1);
994		assert!(model_state.fields.contains_key("title"));
995	}
996
997	#[test]
998	fn test_unique_field_uses_stable_table_constraint_without_inline_duplicate() {
999		// Arrange
1000		let mut metadata = ModelMetadata::new("auth", "RenamedEmailVerificationToken", "auth_evt");
1001		metadata.add_field(
1002			"token_hash".to_string(),
1003			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1004		);
1005
1006		// Act
1007		let model_state = metadata.to_model_state();
1008		let mut to_state = ProjectState::new();
1009		to_state.add_model(model_state);
1010		let migrations =
1011			MigrationAutodetector::new(ProjectState::new(), to_state).generate_migrations();
1012
1013		// Assert
1014		let model_state = &migrations[0].operations;
1015		let Operation::CreateTable {
1016			columns,
1017			constraints,
1018			..
1019		} = &model_state[0]
1020		else {
1021			panic!("expected an initial CreateTable operation");
1022		};
1023		let expected_constraint_name = format!(
1024			"auth_evt_token_hash_uniq_{:08x}",
1025			stable_constraint_name_hash("auth_evt\0token_hash")
1026		);
1027		assert_eq!(
1028			columns
1029				.iter()
1030				.filter(|column| column.name == "token_hash" && column.unique)
1031				.count(),
1032			0,
1033			"single-column uniqueness must not be emitted inline"
1034		);
1035		assert_eq!(
1036			constraints,
1037			&vec![Constraint::Unique {
1038				name: expected_constraint_name,
1039				columns: vec!["token_hash".to_string()],
1040			}],
1041			"the physical constraint name must derive from the stable table name"
1042		);
1043		assert_eq!(
1044			model_state[0]
1045				.to_sql(&SqlDialect::Postgres)
1046				.matches("UNIQUE")
1047				.count(),
1048			1,
1049			"the generated PostgreSQL DDL must contain one UNIQUE representation"
1050		);
1051	}
1052
1053	#[test]
1054	fn test_explicit_single_field_unique_constraint_name_is_preserved() {
1055		// Arrange
1056		let mut metadata = ModelMetadata::new("auth", "Token", "auth_evt");
1057		metadata.add_field(
1058			"token_hash".to_string(),
1059			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1060		);
1061		metadata.add_constraint(ConstraintDefinition {
1062			name: "auth_evt_token_hash_uniq".to_string(),
1063			constraint_type: "unique".to_string(),
1064			fields: vec!["token_hash".to_string()],
1065			expression: None,
1066			foreign_key_info: None,
1067		});
1068
1069		// Act
1070		let model_state = metadata.to_model_state();
1071
1072		// Assert
1073		assert!(
1074			!model_state.fields["token_hash"]
1075				.params
1076				.contains_key("unique")
1077		);
1078		assert_eq!(model_state.constraints.len(), 1);
1079		assert_eq!(model_state.constraints[0].name, "auth_evt_token_hash_uniq");
1080	}
1081
1082	#[test]
1083	fn test_synthesized_unique_constraint_avoids_model_constraint_name_collision() {
1084		// Arrange
1085		let mut metadata = ModelMetadata::new("accounts", "Account", "accounts");
1086		metadata.add_field(
1087			"a_b".to_string(),
1088			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1089		);
1090		metadata.add_field("a".to_string(), FieldMetadata::new(FieldType::VarChar(255)));
1091		metadata.add_field("b".to_string(), FieldMetadata::new(FieldType::VarChar(255)));
1092		metadata.add_constraint(ConstraintDefinition {
1093			name: "accounts_a_b_uniq".to_string(),
1094			constraint_type: "unique".to_string(),
1095			fields: vec!["a".to_string(), "b".to_string()],
1096			expression: None,
1097			foreign_key_info: None,
1098		});
1099
1100		// Act
1101		let model_state = metadata.to_model_state();
1102
1103		// Assert
1104		let mut names: Vec<_> = model_state
1105			.constraints
1106			.iter()
1107			.map(|constraint| constraint.name.clone())
1108			.collect();
1109		names.sort_unstable();
1110		let generated_name = format!(
1111			"accounts_a_b_uniq_{:08x}",
1112			stable_constraint_name_hash("accounts\0a_b")
1113		);
1114		assert_eq!(names, vec!["accounts_a_b_uniq".to_string(), generated_name]);
1115	}
1116
1117	#[test]
1118	fn test_synthesized_unique_constraint_avoids_foreign_key_name_collision() {
1119		// Arrange
1120		let mut metadata = ModelMetadata::new("billing", "Account", "fk");
1121		metadata.add_field(
1122			"fk_x".to_string(),
1123			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1124		);
1125		metadata.add_field(
1126			"x_uniq".to_string(),
1127			FieldMetadata::new(FieldType::Integer).with_foreign_key(ForeignKeyInfo {
1128				referenced_table: "users".to_string(),
1129				referenced_column: "id".to_string(),
1130				on_delete: crate::migrations::ForeignKeyAction::Cascade,
1131				on_update: crate::migrations::ForeignKeyAction::NoAction,
1132			}),
1133		);
1134
1135		// Act
1136		let model_state = metadata.to_model_state();
1137
1138		// Assert
1139		let mut names: Vec<_> = model_state
1140			.constraints
1141			.iter()
1142			.map(|constraint| constraint.name.clone())
1143			.collect();
1144		names.sort_unstable();
1145		let generated_name = format!(
1146			"fk_fk_x_uniq_{:08x}",
1147			stable_constraint_name_hash("fk\0fk_x")
1148		);
1149		assert_eq!(names, vec!["fk_fk_x_uniq".to_string(), generated_name]);
1150	}
1151
1152	#[test]
1153	fn test_synthesized_unique_constraint_names_avoid_normalized_field_collisions() {
1154		// Arrange
1155		let mut metadata = ModelMetadata::new("accounts", "Account", "accounts");
1156		metadata.add_field(
1157			"é".to_string(),
1158			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1159		);
1160		metadata.add_field(
1161			"ü".to_string(),
1162			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1163		);
1164
1165		// Act
1166		let model_state = metadata.to_model_state();
1167
1168		// Assert
1169		let mut names: Vec<_> = model_state
1170			.constraints
1171			.iter()
1172			.map(|constraint| constraint.name.clone())
1173			.collect();
1174		names.sort_unstable();
1175		let mut expected_names = ["é", "ü"]
1176			.into_iter()
1177			.map(|field| {
1178				format!(
1179					"accounts___uniq_{:08x}",
1180					stable_constraint_name_hash(&format!("accounts\0{field}"))
1181				)
1182			})
1183			.collect::<Vec<_>>();
1184		expected_names.sort_unstable();
1185		assert_eq!(names, expected_names);
1186	}
1187
1188	#[test]
1189	fn test_synthesized_unique_constraint_name_is_stable_when_normalized_field_is_added() {
1190		// Arrange
1191		let mut existing = ModelMetadata::new("accounts", "Account", "accounts");
1192		existing.add_field(
1193			"ü".to_string(),
1194			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1195		);
1196		let existing_name = existing.to_model_state().constraints[0].name.clone();
1197
1198		let mut expanded = ModelMetadata::new("accounts", "Account", "accounts");
1199		expanded.add_field(
1200			"é".to_string(),
1201			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1202		);
1203		expanded.add_field(
1204			"ü".to_string(),
1205			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1206		);
1207
1208		// Act
1209		let expanded_state = expanded.to_model_state();
1210		let expanded_name = expanded_state
1211			.constraints
1212			.iter()
1213			.find(|constraint| constraint.fields == vec!["ü".to_string()])
1214			.expect("expanded model must retain the existing unique field")
1215			.name
1216			.clone();
1217
1218		// Assert
1219		assert_eq!(existing_name, expanded_name);
1220	}
1221
1222	#[test]
1223	fn test_synthesized_unique_constraint_names_encode_table_field_boundaries() {
1224		// Arrange
1225		let mut first = ModelMetadata::new("accounts", "First", "a_b");
1226		first.add_field(
1227			"c".to_string(),
1228			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1229		);
1230		let mut second = ModelMetadata::new("accounts", "Second", "a");
1231		second.add_field(
1232			"b_c".to_string(),
1233			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1234		);
1235
1236		// Act
1237		let first_name = first.to_model_state().constraints[0].name.clone();
1238		let second_name = second.to_model_state().constraints[0].name.clone();
1239
1240		// Assert
1241		assert_ne!(first_name, second_name);
1242	}
1243
1244	#[test]
1245	fn test_synthesized_unique_constraint_name_is_safe_for_custom_table_names() {
1246		// Arrange
1247		let mut metadata = ModelMetadata::new("accounts", "Account", "User-Events");
1248		metadata.add_field(
1249			"token".to_string(),
1250			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1251		);
1252
1253		// Act
1254		let model_state = metadata.to_model_state();
1255		let constraint_name = model_state.constraints[0].name.clone();
1256		let expected_constraint_name = format!(
1257			"user_events_{:08x}_token_uniq_{:08x}",
1258			stable_constraint_name_hash("User-Events"),
1259			stable_constraint_name_hash("User-Events\0token")
1260		);
1261		let mut to_state = ProjectState::new();
1262		to_state.add_model(model_state);
1263		let migrations =
1264			MigrationAutodetector::new(ProjectState::new(), to_state).generate_migrations();
1265		let sql = migrations[0].operations[0].to_sql(&SqlDialect::Postgres);
1266
1267		// Assert
1268		assert_eq!(constraint_name, expected_constraint_name);
1269		assert_eq!(
1270			sql,
1271			format!(
1272				"CREATE TABLE \"User-Events\" (\n  token VARCHAR(255) NOT NULL,\n  CONSTRAINT {expected_constraint_name} UNIQUE (token)\n);"
1273			)
1274		);
1275	}
1276
1277	#[test]
1278	fn test_synthesized_unique_constraint_names_are_distinct_for_normalized_tables() {
1279		// Arrange
1280		let mut dashed = ModelMetadata::new("accounts", "Dashed", "User-Events");
1281		dashed.add_field(
1282			"token".to_string(),
1283			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1284		);
1285		let mut underscored = ModelMetadata::new("accounts", "Underscored", "user_events");
1286		underscored.add_field(
1287			"token".to_string(),
1288			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1289		);
1290
1291		// Act
1292		let dashed_name = dashed.to_model_state().constraints[0].name.clone();
1293		let underscored_name = underscored.to_model_state().constraints[0].name.clone();
1294
1295		// Assert
1296		assert_ne!(dashed_name, underscored_name);
1297	}
1298
1299	#[test]
1300	fn test_synthesized_unique_constraint_names_are_bounded_and_distinct() {
1301		// Arrange
1302		let long_table = "t".repeat(40);
1303		let long_field = "f".repeat(40);
1304		let other_field = format!("{}g", "f".repeat(39));
1305		let mut metadata = ModelMetadata::new("accounts", "Account", long_table);
1306		metadata.add_field(
1307			long_field,
1308			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1309		);
1310		metadata.add_field(
1311			other_field,
1312			FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
1313		);
1314
1315		// Act
1316		let constraints = metadata.to_model_state().constraints;
1317
1318		// Assert
1319		assert_eq!(constraints.len(), 2);
1320		assert!(constraints.iter().all(|constraint| {
1321			constraint.name.len() <= ModelMetadata::MAX_CONSTRAINT_IDENTIFIER_BYTES
1322		}));
1323		assert_ne!(constraints[0].name, constraints[1].name);
1324	}
1325
1326	#[test]
1327	fn test_field_metadata_builder() {
1328		let field = FieldMetadata::new(FieldType::Custom("CharField".to_string()))
1329			.with_param("max_length", "100")
1330			.with_nullable(false);
1331
1332		assert_eq!(field.field_type, FieldType::Custom("CharField".to_string()));
1333		assert_eq!(field.params.get("max_length").unwrap(), "100");
1334		assert!(!field.nullable);
1335		assert_eq!(field.params.get("null").unwrap(), "false");
1336
1337		let field =
1338			FieldMetadata::new(FieldType::Custom("IntegerField".to_string())).with_nullable(true);
1339		assert!(field.nullable);
1340		assert_eq!(field.params.get("null").unwrap(), "true");
1341	}
1342
1343	#[rstest]
1344	#[case(true, true)]
1345	#[case(false, false)]
1346	fn test_to_model_state_overrides_nullable_from_params(
1347		#[case] nullable: bool,
1348		#[case] expected_nullable: bool,
1349	) {
1350		// Arrange
1351		let mut metadata = ModelMetadata::new("blog", "Post", "blog_post");
1352		let field = FieldMetadata::new(FieldType::Custom("CharField".to_string()))
1353			.with_param("max_length", "200")
1354			.with_nullable(nullable);
1355		metadata.add_field("description".to_string(), field);
1356
1357		// Act
1358		let model_state = metadata.to_model_state();
1359
1360		// Assert
1361		let field_state = model_state.fields.get("description").unwrap();
1362		assert_eq!(field_state.nullable, expected_nullable);
1363		assert!(
1364			!field_state.params.contains_key("null"),
1365			"params must not contain `null` key after to_model_state 			 (it is already carried by FieldState.nullable)"
1366		);
1367	}
1368
1369	#[rstest]
1370	fn to_model_state_nullable_false_for_primary_key_matches_macro_contract() {
1371		// Arrange — regression for issue #4052.
1372		//
1373		// The `#[model]` macro must emit `null = "false"` for primary key
1374		// fields regardless of whether the Rust type is `Option<T>`. The
1375		// `Option<T>` wrapper for PKs is a Rust-side convention to allow
1376		// `id = None` before the DB assigns the auto-increment value, not
1377		// a DB-level nullability statement. PK columns are always NOT NULL
1378		// at the DB level.
1379		//
1380		// This test codifies the contract that `to_model_state` consumes
1381		// from the macro: with the fixed macro params, the resulting
1382		// `FieldState.nullable` for an `Option<i64>` PK must be `false`,
1383		// matching the migration-replay path's
1384		// `column_def_to_field_state(...).nullable = !col.not_null = false`.
1385		//
1386		// Pre-fix, the macro emitted `null = "true"` for any Option<T>
1387		// field including PKs, producing `FieldState.nullable = true` and
1388		// surfacing as a spurious `AlterColumn` for the unchanged PK in
1389		// offline `makemigrations` runs.
1390		let mut metadata = ModelMetadata::new("clusters", "Cluster", "clusters");
1391		// Mirror the fixed macro params for `id: Option<i64>` with
1392		// `#[field(primary_key = true)]`: `null = "false"` (forced by the
1393		// fix), `not_null = "true"`, `primary_key = "true"`,
1394		// `auto_increment = "true"`.
1395		let id_field = FieldMetadata::new(FieldType::BigInteger)
1396			.with_param("primary_key", "true")
1397			.with_param("auto_increment", "true")
1398			.with_param("not_null", "true")
1399			.with_nullable(false);
1400		metadata.add_field("id".to_string(), id_field);
1401
1402		// Act
1403		let model_state = metadata.to_model_state();
1404
1405		// Assert — nullable=false on the FieldState side, regardless of
1406		// the underlying Rust Option<T> wrapping.
1407		let id_state = model_state
1408			.fields
1409			.get("id")
1410			.expect("id field present in to_model_state output");
1411		assert!(
1412			!id_state.nullable,
1413			"PK FieldState.nullable must be false even when the Rust type is \
1414			 Option<i64>. Did the #[model] macro regress to emitting \
1415			 null=\"true\" for Option<T> PKs? params={:?}",
1416			id_state.params
1417		);
1418		assert!(
1419			!id_state.params.contains_key("null"),
1420			"PK params must not contain `null` after to_model_state \
1421			 (nullable is already carried by FieldState.nullable). \
1422			 Got params={:?}",
1423			id_state.params
1424		);
1425	}
1426
1427	#[test]
1428	fn to_model_state_materializes_default_db_index() {
1429		// Arrange
1430		let mut metadata = ModelMetadata::new("blog", "Post", "blog_posts");
1431		metadata.add_field(
1432			"author_id".to_string(),
1433			FieldMetadata::new(FieldType::Uuid).with_param("db_index", "true"),
1434		);
1435
1436		// Act
1437		let model_state = metadata.to_model_state();
1438
1439		// Assert
1440		assert_eq!(model_state.indexes.len(), 1);
1441		assert_eq!(model_state.indexes[0].fields, vec!["author_id"]);
1442		assert!(!model_state.indexes[0].unique);
1443	}
1444
1445	#[test]
1446	fn to_model_state_skips_index_for_unique_field_or_disabled_field() {
1447		// Arrange
1448		let mut metadata = ModelMetadata::new("blog", "Post", "blog_posts");
1449		metadata.add_field(
1450			"author_id".to_string(),
1451			FieldMetadata::new(FieldType::Uuid)
1452				.with_param("db_index", "true")
1453				.with_param("unique", "true"),
1454		);
1455		metadata.add_field(
1456			"category_id".to_string(),
1457			FieldMetadata::new(FieldType::Uuid).with_param("db_index", "false"),
1458		);
1459
1460		// Act
1461		let model_state = metadata.to_model_state();
1462
1463		// Assert
1464		assert!(model_state.indexes.is_empty());
1465	}
1466
1467	#[test]
1468	fn to_model_state_deduplicates_equivalent_explicit_index() {
1469		// Arrange
1470		let mut metadata = ModelMetadata::new("blog", "Post", "blog_posts");
1471		metadata.add_field(
1472			"author_id".to_string(),
1473			FieldMetadata::new(FieldType::Uuid).with_param("db_index", "true"),
1474		);
1475		metadata.add_index(IndexDefinition {
1476			name: "posts_author_explicit".to_string(),
1477			fields: vec!["author_id".to_string()],
1478			unique: false,
1479			where_clause: None,
1480			index_type: None,
1481			expressions: None,
1482			concurrently: false,
1483			mysql_options: None,
1484			operator_class: None,
1485		});
1486
1487		// Act
1488		let model_state = metadata.to_model_state();
1489
1490		// Assert
1491		assert_eq!(model_state.indexes.len(), 1);
1492		assert_eq!(model_state.indexes[0].name, "posts_author_explicit");
1493	}
1494}