use super::ConstraintDefinition;
use super::autodetector::{
FieldState, IndexDefinition, ModelState, default_index_name, index_definitions_equivalent,
};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
#[cfg_attr(doc, aquamarine::aquamarine)]
#[derive(Debug, Clone)]
pub struct ModelMetadata {
pub app_label: String,
pub model_name: String,
pub table_name: String,
pub fields: HashMap<String, FieldMetadata>,
pub options: HashMap<String, String>,
pub many_to_many_fields: Vec<ManyToManyMetadata>,
constraints: Vec<ConstraintDefinition>,
indexes: Vec<IndexDefinition>,
}
impl ModelMetadata {
const MAX_CONSTRAINT_IDENTIFIER_BYTES: usize = 63;
pub fn new(
app_label: impl Into<String>,
model_name: impl Into<String>,
table_name: impl Into<String>,
) -> Self {
Self {
app_label: app_label.into(),
model_name: model_name.into(),
table_name: table_name.into(),
fields: HashMap::new(),
options: HashMap::new(),
many_to_many_fields: Vec::new(),
constraints: Vec::new(),
indexes: Vec::new(),
}
}
pub fn add_field(&mut self, name: String, field: FieldMetadata) {
self.fields.insert(name, field);
}
pub fn set_option(&mut self, key: String, value: String) {
self.options.insert(key, value);
}
pub fn add_many_to_many(&mut self, m2m: ManyToManyMetadata) {
self.many_to_many_fields.push(m2m);
}
pub fn add_constraint(&mut self, constraint: ConstraintDefinition) {
self.constraints.push(constraint);
}
fn synthesized_unique_constraint_name(
&self,
field_name: &str,
generated_names: &HashSet<String>,
existing_constraints: &[ConstraintDefinition],
) -> String {
let tuple_digest =
stable_constraint_name_hash(&format!("{}\0{}", self.table_name, field_name));
let base_name = bounded_constraint_identifier(&format!(
"{}_{}_uniq_{tuple_digest:08x}",
safe_constraint_table_fragment(&self.table_name),
safe_constraint_name_fragment(field_name)
));
let is_taken = |candidate: &str| {
self.constraints
.iter()
.any(|constraint| constraint.name.eq_ignore_ascii_case(candidate))
|| existing_constraints
.iter()
.any(|constraint| constraint.name.eq_ignore_ascii_case(candidate))
|| generated_names
.iter()
.any(|name| name.eq_ignore_ascii_case(candidate))
};
if !is_taken(&base_name) {
return base_name;
}
let field_digest = stable_constraint_name_hash(field_name);
let mut candidate =
bounded_constraint_identifier(&format!("{base_name}_field_{field_digest:08x}"));
let mut suffix = 2;
while is_taken(&candidate) {
candidate = bounded_constraint_identifier(&format!(
"{base_name}_field_{field_digest:08x}_{suffix}"
));
suffix += 1;
}
candidate
}
pub fn constraints(&self) -> &[ConstraintDefinition] {
&self.constraints
}
pub fn add_index(&mut self, index: IndexDefinition) {
self.indexes.push(index);
}
pub fn indexes(&self) -> &[IndexDefinition] {
&self.indexes
}
pub fn to_model_state(&self) -> ModelState {
let mut model_state = ModelState::new(&self.app_label, &self.model_name);
model_state.table_name = self.table_name.clone();
for (name, field_meta) in &self.fields {
let is_unique = field_meta.params.get("unique").map(String::as_str) == Some("true");
let mut field_state = FieldState::new(
name.clone(),
field_meta.field_type.clone(),
field_meta.nullable,
);
for (key, value) in &field_meta.params {
if key == "null" || (is_unique && key == "unique") {
continue;
}
field_state.params.insert(key.clone(), value.clone());
}
if let Some(ref fk_info) = field_meta.foreign_key {
field_state.foreign_key = Some(fk_info.clone());
}
model_state.add_field(field_state);
}
model_state.options = self.options.clone();
for (field_name, field_meta) in &self.fields {
if field_meta.foreign_key.is_some() {
model_state.add_foreign_key_constraint_from_field(field_name);
}
}
model_state.many_to_many_fields = self.many_to_many_fields.clone();
model_state.indexes.extend(self.indexes.iter().cloned());
let mut synthesized_indexes = self
.fields
.iter()
.filter_map(|(field_name, field_meta)| {
let has_default_index =
field_meta.params.get("db_index").map(String::as_str) == Some("true");
let is_unique = field_meta.params.get("unique").map(String::as_str) == Some("true")
|| field_meta.params.get("primary_key").map(String::as_str) == Some("true");
if !has_default_index || is_unique {
return None;
}
Some(IndexDefinition {
name: default_index_name(&self.table_name, std::slice::from_ref(field_name)),
fields: vec![field_name.clone()],
unique: false,
where_clause: None,
index_type: None,
expressions: None,
concurrently: false,
mysql_options: None,
operator_class: None,
})
})
.collect::<Vec<_>>();
synthesized_indexes.sort_by(|left, right| left.name.cmp(&right.name));
for index in synthesized_indexes {
if !model_state
.indexes
.iter()
.any(|existing| index_definitions_equivalent(existing, &index))
{
model_state.indexes.push(index);
}
}
let mut generated_unique_constraint_names = HashSet::new();
let mut unique_fields = self
.fields
.iter()
.filter(|(_, field_meta)| {
field_meta.params.get("unique").map(String::as_str) == Some("true")
})
.collect::<Vec<_>>();
unique_fields.sort_unstable_by_key(|(left, _)| *left);
for (field_name, field_meta) in unique_fields {
if field_meta.params.get("unique").map(String::as_str) == Some("true") {
if self.constraints.iter().any(|constraint| {
constraint.constraint_type.eq_ignore_ascii_case("unique")
&& constraint.fields.len() == 1
&& constraint.fields[0] == *field_name
}) {
continue;
}
let constraint = ConstraintDefinition {
name: self.synthesized_unique_constraint_name(
field_name,
&generated_unique_constraint_names,
&model_state.constraints,
),
constraint_type: "unique".to_string(),
fields: vec![field_name.clone()],
expression: None,
foreign_key_info: None,
};
generated_unique_constraint_names.insert(constraint.name.clone());
model_state.constraints.push(constraint);
}
}
model_state
.constraints
.extend(self.constraints.iter().cloned());
model_state
}
}
fn safe_constraint_name_fragment(value: &str) -> String {
let mut fragment = String::with_capacity(value.len());
for character in value.chars() {
if character.is_ascii_alphanumeric() || character == '_' {
fragment.push(character.to_ascii_lowercase());
} else {
fragment.push('_');
}
}
if fragment.is_empty() {
fragment.push_str("table");
} else if fragment
.as_bytes()
.first()
.is_some_and(|character| character.is_ascii_digit())
{
fragment.insert_str(0, "table_");
}
fragment
}
fn safe_constraint_table_fragment(value: &str) -> String {
let fragment = safe_constraint_name_fragment(value);
if fragment == value {
return fragment;
}
format!("{fragment}_{:08x}", stable_constraint_name_hash(value))
}
fn stable_constraint_name_hash(value: &str) -> u32 {
let mut hash = 0x811c9dc5_u32;
for byte in value.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x01000193);
}
hash
}
fn bounded_constraint_identifier(value: &str) -> String {
if value.len() <= ModelMetadata::MAX_CONSTRAINT_IDENTIFIER_BYTES {
return value.to_owned();
}
let suffix = format!("_{:08x}", stable_constraint_name_hash(value));
let prefix_len = ModelMetadata::MAX_CONSTRAINT_IDENTIFIER_BYTES - suffix.len();
let mut end = prefix_len;
while !value.is_char_boundary(end) {
end -= 1;
}
format!("{}{}", &value[..end], suffix)
}
#[derive(Debug, Clone)]
pub struct FieldMetadata {
pub field_type: super::FieldType,
pub nullable: bool,
pub params: HashMap<String, String>,
pub foreign_key: Option<super::autodetector::ForeignKeyInfo>,
}
impl FieldMetadata {
pub fn new(field_type: super::FieldType) -> Self {
Self {
field_type,
nullable: false,
params: HashMap::new(),
foreign_key: None,
}
}
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let key_s: String = key.into();
let value_s: String = value.into();
if key_s == "null" {
let parsed = value_s.parse::<bool>().unwrap_or(false);
self.nullable = parsed;
self.params.insert(key_s, parsed.to_string());
return self;
}
self.params.insert(key_s, value_s);
self
}
pub fn with_nullable(mut self, nullable: bool) -> Self {
self.nullable = nullable;
self.params.insert("null".to_string(), nullable.to_string());
self
}
pub fn is_nullable(&self) -> bool {
self.nullable
}
pub fn with_foreign_key(mut self, foreign_key: super::autodetector::ForeignKeyInfo) -> Self {
self.foreign_key = Some(foreign_key);
self
}
}
#[derive(Debug, Clone)]
pub struct RelationshipMetadata {
pub field_name: String,
pub rel_type: String,
pub to_model: Option<String>,
pub related_name: Option<String>,
pub through_table: Option<String>,
pub composite: Option<String>,
pub source_app_label: Option<String>,
pub source_model_name: Option<String>,
}
impl RelationshipMetadata {
pub fn new(field_name: impl Into<String>, rel_type: impl Into<String>) -> Self {
Self {
field_name: field_name.into(),
rel_type: rel_type.into(),
to_model: None,
related_name: None,
through_table: None,
composite: None,
source_app_label: None,
source_model_name: None,
}
}
pub fn with_to_model(mut self, to_model: impl Into<String>) -> Self {
self.to_model = Some(to_model.into());
self
}
pub fn with_related_name(mut self, related_name: impl Into<String>) -> Self {
self.related_name = Some(related_name.into());
self
}
pub fn with_through_table(mut self, through_table: impl Into<String>) -> Self {
self.through_table = Some(through_table.into());
self
}
pub fn with_composite(mut self, composite: impl Into<String>) -> Self {
self.composite = Some(composite.into());
self
}
pub fn with_source_info(
mut self,
app_label: impl Into<String>,
model_name: impl Into<String>,
) -> Self {
self.source_app_label = Some(app_label.into());
self.source_model_name = Some(model_name.into());
self
}
pub fn is_many_to_many(&self) -> bool {
self.rel_type == "many_to_many" || self.rel_type == "polymorphic_many_to_many"
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ManyToManyMetadata {
pub field_name: String,
pub to_model: String,
pub related_name: Option<String>,
pub through: Option<String>,
pub source_field: Option<String>,
pub target_field: Option<String>,
pub db_constraint_prefix: Option<String>,
}
impl ManyToManyMetadata {
pub fn new(field_name: impl Into<String>, to_model: impl Into<String>) -> Self {
Self {
field_name: field_name.into(),
to_model: to_model.into(),
related_name: None,
through: None,
source_field: None,
target_field: None,
db_constraint_prefix: None,
}
}
pub fn with_related_name(mut self, related_name: impl Into<String>) -> Self {
self.related_name = Some(related_name.into());
self
}
pub fn with_through(mut self, through: impl Into<String>) -> Self {
self.through = Some(through.into());
self
}
pub fn with_source_field(mut self, source_field: impl Into<String>) -> Self {
self.source_field = Some(source_field.into());
self
}
pub fn with_target_field(mut self, target_field: impl Into<String>) -> Self {
self.target_field = Some(target_field.into());
self
}
pub fn with_db_constraint_prefix(mut self, prefix: impl Into<String>) -> Self {
self.db_constraint_prefix = Some(prefix.into());
self
}
}
#[derive(Debug, Clone)]
pub struct ModelRegistry {
models: Arc<RwLock<HashMap<(String, String), ModelMetadata>>>,
}
impl ModelRegistry {
pub fn new() -> Self {
Self {
models: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn register_model(&self, metadata: ModelMetadata) {
let key = (metadata.app_label.clone(), metadata.model_name.clone());
if let Ok(mut models) = self.models.write() {
models.insert(key, metadata);
}
}
pub fn get_models(&self) -> Vec<ModelMetadata> {
if let Ok(models) = self.models.read() {
models.values().cloned().collect()
} else {
Vec::new()
}
}
pub fn get_model(&self, app_label: &str, model_name: &str) -> Option<ModelMetadata> {
if let Ok(models) = self.models.read() {
models
.get(&(app_label.to_string(), model_name.to_string()))
.cloned()
} else {
None
}
}
pub fn find_model_qualified(&self, app_label: &str, model_name: &str) -> Option<ModelMetadata> {
self.get_model(app_label, model_name)
}
pub fn find_model_by_name(&self, model_name: &str) -> Option<ModelMetadata> {
let models = self.models.read().ok()?;
let mut matches = models.values().filter(|m| m.model_name == model_name);
let first = matches.next()?.clone();
if matches.next().is_some() {
tracing::warn!(
model_name,
"ModelRegistry::find_model_by_name: ambiguous model name registered \
under multiple app labels; returning None. Use \
ModelRegistry::find_model_qualified(app, name) to disambiguate.",
);
return None;
}
Some(first)
}
pub fn count_models_by_name(&self, model_name: &str) -> usize {
if let Ok(models) = self.models.read() {
models
.values()
.filter(|m| m.model_name == model_name)
.count()
} else {
0
}
}
pub fn get_app_models(&self, app_label: &str) -> Vec<ModelMetadata> {
if let Ok(models) = self.models.read() {
models
.iter()
.filter(|((app, _), _)| app == app_label)
.map(|(_, meta)| meta.clone())
.collect()
} else {
Vec::new()
}
}
pub fn remove_model(&self, app_label: &str, model_name: &str) -> bool {
if let Ok(mut models) = self.models.write() {
models
.remove(&(app_label.to_string(), model_name.to_string()))
.is_some()
} else {
false
}
}
pub fn clear(&self) {
if let Ok(mut models) = self.models.write() {
models.clear();
}
}
pub fn count(&self) -> usize {
if let Ok(models) = self.models.read() {
models.len()
} else {
0
}
}
}
impl Default for ModelRegistry {
fn default() -> Self {
Self::new()
}
}
pub fn global_registry() -> &'static ModelRegistry {
use once_cell::sync::Lazy;
static REGISTRY: Lazy<ModelRegistry> = Lazy::new(ModelRegistry::new);
®ISTRY
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrations::FieldType;
use crate::migrations::autodetector::{ForeignKeyInfo, MigrationAutodetector, ProjectState};
use crate::migrations::operations::{Constraint, Operation, SqlDialect};
use rstest::rstest;
#[test]
fn test_model_registry_new() {
let registry = ModelRegistry::new();
assert_eq!(registry.count(), 0);
}
#[test]
fn test_register_model() {
let registry = ModelRegistry::new();
let metadata = ModelMetadata::new("blog", "Post", "blog_post");
registry.register_model(metadata);
assert_eq!(registry.count(), 1);
}
#[test]
fn test_get_model() {
let registry = ModelRegistry::new();
let metadata = ModelMetadata::new("auth", "User", "auth_user");
registry.register_model(metadata);
let retrieved = registry.get_model("auth", "User");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().table_name, "auth_user");
}
#[test]
fn test_get_models() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
let models = registry.get_models();
assert_eq!(models.len(), 2);
}
#[test]
fn test_find_model_qualified_hit() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
let hit = registry.find_model_qualified("auth", "User");
assert!(hit.is_some());
let model = hit.unwrap();
assert_eq!(model.app_label, "auth");
assert_eq!(model.model_name, "User");
assert_eq!(model.table_name, "auth_user");
}
#[test]
fn test_find_model_qualified_miss_wrong_app() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
assert!(registry.find_model_qualified("billing", "User").is_none());
}
#[test]
fn test_find_model_by_name_unique() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
let hit = registry.find_model_by_name("Post");
assert!(hit.is_some());
assert_eq!(hit.unwrap().app_label, "blog");
}
#[test]
fn test_find_model_by_name_missing() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
assert!(registry.find_model_by_name("NoSuchModel").is_none());
}
#[test]
fn test_find_model_by_name_ambiguous_returns_none() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("billing", "User", "billing_user"));
let hit = registry.find_model_by_name("User");
assert!(hit.is_none());
}
#[test]
fn test_get_app_models() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("auth", "Group", "auth_group"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
let auth_models = registry.get_app_models("auth");
assert_eq!(auth_models.len(), 2);
let blog_models = registry.get_app_models("blog");
assert_eq!(blog_models.len(), 1);
}
#[test]
fn test_remove_model() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
assert!(registry.remove_model("auth", "User"));
assert_eq!(registry.count(), 0);
}
#[test]
fn test_migrations_registry_clear() {
let registry = ModelRegistry::new();
registry.register_model(ModelMetadata::new("auth", "User", "auth_user"));
registry.register_model(ModelMetadata::new("blog", "Post", "blog_post"));
registry.clear();
assert_eq!(registry.count(), 0);
}
#[test]
fn test_model_metadata_to_model_state() {
let mut metadata = ModelMetadata::new("blog", "Post", "blog_post");
let mut title_field = FieldMetadata::new(FieldType::Custom("CharField".to_string()));
title_field
.params
.insert("max_length".to_string(), "200".to_string());
metadata.add_field("title".to_string(), title_field);
let model_state = metadata.to_model_state();
assert_eq!(model_state.name, "Post");
assert_eq!(model_state.fields.len(), 1);
assert!(model_state.fields.contains_key("title"));
}
#[test]
fn test_unique_field_uses_stable_table_constraint_without_inline_duplicate() {
let mut metadata = ModelMetadata::new("auth", "RenamedEmailVerificationToken", "auth_evt");
metadata.add_field(
"token_hash".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let model_state = metadata.to_model_state();
let mut to_state = ProjectState::new();
to_state.add_model(model_state);
let migrations =
MigrationAutodetector::new(ProjectState::new(), to_state).generate_migrations();
let model_state = &migrations[0].operations;
let Operation::CreateTable {
columns,
constraints,
..
} = &model_state[0]
else {
panic!("expected an initial CreateTable operation");
};
let expected_constraint_name = format!(
"auth_evt_token_hash_uniq_{:08x}",
stable_constraint_name_hash("auth_evt\0token_hash")
);
assert_eq!(
columns
.iter()
.filter(|column| column.name == "token_hash" && column.unique)
.count(),
0,
"single-column uniqueness must not be emitted inline"
);
assert_eq!(
constraints,
&vec![Constraint::Unique {
name: expected_constraint_name,
columns: vec!["token_hash".to_string()],
}],
"the physical constraint name must derive from the stable table name"
);
assert_eq!(
model_state[0]
.to_sql(&SqlDialect::Postgres)
.matches("UNIQUE")
.count(),
1,
"the generated PostgreSQL DDL must contain one UNIQUE representation"
);
}
#[test]
fn test_explicit_single_field_unique_constraint_name_is_preserved() {
let mut metadata = ModelMetadata::new("auth", "Token", "auth_evt");
metadata.add_field(
"token_hash".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
metadata.add_constraint(ConstraintDefinition {
name: "auth_evt_token_hash_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["token_hash".to_string()],
expression: None,
foreign_key_info: None,
});
let model_state = metadata.to_model_state();
assert!(
!model_state.fields["token_hash"]
.params
.contains_key("unique")
);
assert_eq!(model_state.constraints.len(), 1);
assert_eq!(model_state.constraints[0].name, "auth_evt_token_hash_uniq");
}
#[test]
fn test_synthesized_unique_constraint_avoids_model_constraint_name_collision() {
let mut metadata = ModelMetadata::new("accounts", "Account", "accounts");
metadata.add_field(
"a_b".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
metadata.add_field("a".to_string(), FieldMetadata::new(FieldType::VarChar(255)));
metadata.add_field("b".to_string(), FieldMetadata::new(FieldType::VarChar(255)));
metadata.add_constraint(ConstraintDefinition {
name: "accounts_a_b_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["a".to_string(), "b".to_string()],
expression: None,
foreign_key_info: None,
});
let model_state = metadata.to_model_state();
let mut names: Vec<_> = model_state
.constraints
.iter()
.map(|constraint| constraint.name.clone())
.collect();
names.sort_unstable();
let generated_name = format!(
"accounts_a_b_uniq_{:08x}",
stable_constraint_name_hash("accounts\0a_b")
);
assert_eq!(names, vec!["accounts_a_b_uniq".to_string(), generated_name]);
}
#[test]
fn test_synthesized_unique_constraint_avoids_foreign_key_name_collision() {
let mut metadata = ModelMetadata::new("billing", "Account", "fk");
metadata.add_field(
"fk_x".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
metadata.add_field(
"x_uniq".to_string(),
FieldMetadata::new(FieldType::Integer).with_foreign_key(ForeignKeyInfo {
referenced_table: "users".to_string(),
referenced_column: "id".to_string(),
on_delete: crate::migrations::ForeignKeyAction::Cascade,
on_update: crate::migrations::ForeignKeyAction::NoAction,
}),
);
let model_state = metadata.to_model_state();
let mut names: Vec<_> = model_state
.constraints
.iter()
.map(|constraint| constraint.name.clone())
.collect();
names.sort_unstable();
let generated_name = format!(
"fk_fk_x_uniq_{:08x}",
stable_constraint_name_hash("fk\0fk_x")
);
assert_eq!(names, vec!["fk_fk_x_uniq".to_string(), generated_name]);
}
#[test]
fn test_synthesized_unique_constraint_names_avoid_normalized_field_collisions() {
let mut metadata = ModelMetadata::new("accounts", "Account", "accounts");
metadata.add_field(
"é".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
metadata.add_field(
"ü".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let model_state = metadata.to_model_state();
let mut names: Vec<_> = model_state
.constraints
.iter()
.map(|constraint| constraint.name.clone())
.collect();
names.sort_unstable();
let mut expected_names = ["é", "ü"]
.into_iter()
.map(|field| {
format!(
"accounts___uniq_{:08x}",
stable_constraint_name_hash(&format!("accounts\0{field}"))
)
})
.collect::<Vec<_>>();
expected_names.sort_unstable();
assert_eq!(names, expected_names);
}
#[test]
fn test_synthesized_unique_constraint_name_is_stable_when_normalized_field_is_added() {
let mut existing = ModelMetadata::new("accounts", "Account", "accounts");
existing.add_field(
"ü".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let existing_name = existing.to_model_state().constraints[0].name.clone();
let mut expanded = ModelMetadata::new("accounts", "Account", "accounts");
expanded.add_field(
"é".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
expanded.add_field(
"ü".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let expanded_state = expanded.to_model_state();
let expanded_name = expanded_state
.constraints
.iter()
.find(|constraint| constraint.fields == vec!["ü".to_string()])
.expect("expanded model must retain the existing unique field")
.name
.clone();
assert_eq!(existing_name, expanded_name);
}
#[test]
fn test_synthesized_unique_constraint_names_encode_table_field_boundaries() {
let mut first = ModelMetadata::new("accounts", "First", "a_b");
first.add_field(
"c".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let mut second = ModelMetadata::new("accounts", "Second", "a");
second.add_field(
"b_c".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let first_name = first.to_model_state().constraints[0].name.clone();
let second_name = second.to_model_state().constraints[0].name.clone();
assert_ne!(first_name, second_name);
}
#[test]
fn test_synthesized_unique_constraint_name_is_safe_for_custom_table_names() {
let mut metadata = ModelMetadata::new("accounts", "Account", "User-Events");
metadata.add_field(
"token".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let model_state = metadata.to_model_state();
let constraint_name = model_state.constraints[0].name.clone();
let expected_constraint_name = format!(
"user_events_{:08x}_token_uniq_{:08x}",
stable_constraint_name_hash("User-Events"),
stable_constraint_name_hash("User-Events\0token")
);
let mut to_state = ProjectState::new();
to_state.add_model(model_state);
let migrations =
MigrationAutodetector::new(ProjectState::new(), to_state).generate_migrations();
let sql = migrations[0].operations[0].to_sql(&SqlDialect::Postgres);
assert_eq!(constraint_name, expected_constraint_name);
assert_eq!(
sql,
format!(
"CREATE TABLE \"User-Events\" (\n token VARCHAR(255) NOT NULL,\n CONSTRAINT {expected_constraint_name} UNIQUE (token)\n);"
)
);
}
#[test]
fn test_synthesized_unique_constraint_names_are_distinct_for_normalized_tables() {
let mut dashed = ModelMetadata::new("accounts", "Dashed", "User-Events");
dashed.add_field(
"token".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let mut underscored = ModelMetadata::new("accounts", "Underscored", "user_events");
underscored.add_field(
"token".to_string(),
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let dashed_name = dashed.to_model_state().constraints[0].name.clone();
let underscored_name = underscored.to_model_state().constraints[0].name.clone();
assert_ne!(dashed_name, underscored_name);
}
#[test]
fn test_synthesized_unique_constraint_names_are_bounded_and_distinct() {
let long_table = "t".repeat(40);
let long_field = "f".repeat(40);
let other_field = format!("{}g", "f".repeat(39));
let mut metadata = ModelMetadata::new("accounts", "Account", long_table);
metadata.add_field(
long_field,
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
metadata.add_field(
other_field,
FieldMetadata::new(FieldType::VarChar(255)).with_param("unique", "true"),
);
let constraints = metadata.to_model_state().constraints;
assert_eq!(constraints.len(), 2);
assert!(constraints.iter().all(|constraint| {
constraint.name.len() <= ModelMetadata::MAX_CONSTRAINT_IDENTIFIER_BYTES
}));
assert_ne!(constraints[0].name, constraints[1].name);
}
#[test]
fn test_field_metadata_builder() {
let field = FieldMetadata::new(FieldType::Custom("CharField".to_string()))
.with_param("max_length", "100")
.with_nullable(false);
assert_eq!(field.field_type, FieldType::Custom("CharField".to_string()));
assert_eq!(field.params.get("max_length").unwrap(), "100");
assert!(!field.nullable);
assert_eq!(field.params.get("null").unwrap(), "false");
let field =
FieldMetadata::new(FieldType::Custom("IntegerField".to_string())).with_nullable(true);
assert!(field.nullable);
assert_eq!(field.params.get("null").unwrap(), "true");
}
#[rstest]
#[case(true, true)]
#[case(false, false)]
fn test_to_model_state_overrides_nullable_from_params(
#[case] nullable: bool,
#[case] expected_nullable: bool,
) {
let mut metadata = ModelMetadata::new("blog", "Post", "blog_post");
let field = FieldMetadata::new(FieldType::Custom("CharField".to_string()))
.with_param("max_length", "200")
.with_nullable(nullable);
metadata.add_field("description".to_string(), field);
let model_state = metadata.to_model_state();
let field_state = model_state.fields.get("description").unwrap();
assert_eq!(field_state.nullable, expected_nullable);
assert!(
!field_state.params.contains_key("null"),
"params must not contain `null` key after to_model_state (it is already carried by FieldState.nullable)"
);
}
#[rstest]
fn to_model_state_nullable_false_for_primary_key_matches_macro_contract() {
let mut metadata = ModelMetadata::new("clusters", "Cluster", "clusters");
let id_field = FieldMetadata::new(FieldType::BigInteger)
.with_param("primary_key", "true")
.with_param("auto_increment", "true")
.with_param("not_null", "true")
.with_nullable(false);
metadata.add_field("id".to_string(), id_field);
let model_state = metadata.to_model_state();
let id_state = model_state
.fields
.get("id")
.expect("id field present in to_model_state output");
assert!(
!id_state.nullable,
"PK FieldState.nullable must be false even when the Rust type is \
Option<i64>. Did the #[model] macro regress to emitting \
null=\"true\" for Option<T> PKs? params={:?}",
id_state.params
);
assert!(
!id_state.params.contains_key("null"),
"PK params must not contain `null` after to_model_state \
(nullable is already carried by FieldState.nullable). \
Got params={:?}",
id_state.params
);
}
#[test]
fn to_model_state_materializes_default_db_index() {
let mut metadata = ModelMetadata::new("blog", "Post", "blog_posts");
metadata.add_field(
"author_id".to_string(),
FieldMetadata::new(FieldType::Uuid).with_param("db_index", "true"),
);
let model_state = metadata.to_model_state();
assert_eq!(model_state.indexes.len(), 1);
assert_eq!(model_state.indexes[0].fields, vec!["author_id"]);
assert!(!model_state.indexes[0].unique);
}
#[test]
fn to_model_state_skips_index_for_unique_field_or_disabled_field() {
let mut metadata = ModelMetadata::new("blog", "Post", "blog_posts");
metadata.add_field(
"author_id".to_string(),
FieldMetadata::new(FieldType::Uuid)
.with_param("db_index", "true")
.with_param("unique", "true"),
);
metadata.add_field(
"category_id".to_string(),
FieldMetadata::new(FieldType::Uuid).with_param("db_index", "false"),
);
let model_state = metadata.to_model_state();
assert!(model_state.indexes.is_empty());
}
#[test]
fn to_model_state_deduplicates_equivalent_explicit_index() {
let mut metadata = ModelMetadata::new("blog", "Post", "blog_posts");
metadata.add_field(
"author_id".to_string(),
FieldMetadata::new(FieldType::Uuid).with_param("db_index", "true"),
);
metadata.add_index(IndexDefinition {
name: "posts_author_explicit".to_string(),
fields: vec!["author_id".to_string()],
unique: false,
where_clause: None,
index_type: None,
expressions: None,
concurrently: false,
mysql_options: None,
operator_class: None,
});
let model_state = metadata.to_model_state();
assert_eq!(model_state.indexes.len(), 1);
assert_eq!(model_state.indexes[0].name, "posts_author_explicit");
}
}