use petgraph::Undirected;
use petgraph::graph::Graph;
use petgraph::visit::EdgeRef;
use regex::Regex;
use std::collections::{BTreeMap, HashMap};
use strsim::{jaro_winkler, levenshtein};
use super::model_registry::ManyToManyMetadata;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub enum ForeignKeyAction {
Restrict,
Cascade,
SetNull,
NoAction,
SetDefault,
}
impl ForeignKeyAction {
pub fn to_sql_keyword(&self) -> &'static str {
match self {
ForeignKeyAction::Restrict => "RESTRICT",
ForeignKeyAction::Cascade => "CASCADE",
ForeignKeyAction::SetNull => "SET NULL",
ForeignKeyAction::NoAction => "NO ACTION",
ForeignKeyAction::SetDefault => "SET DEFAULT",
}
}
}
impl From<ForeignKeyAction> for reinhardt_query::prelude::ForeignKeyAction {
fn from(action: ForeignKeyAction) -> Self {
match action {
ForeignKeyAction::Restrict => reinhardt_query::prelude::ForeignKeyAction::Restrict,
ForeignKeyAction::Cascade => reinhardt_query::prelude::ForeignKeyAction::Cascade,
ForeignKeyAction::SetNull => reinhardt_query::prelude::ForeignKeyAction::SetNull,
ForeignKeyAction::NoAction => reinhardt_query::prelude::ForeignKeyAction::NoAction,
ForeignKeyAction::SetDefault => reinhardt_query::prelude::ForeignKeyAction::SetDefault,
}
}
}
impl From<reinhardt_query::prelude::ForeignKeyAction> for ForeignKeyAction {
fn from(action: reinhardt_query::prelude::ForeignKeyAction) -> Self {
match action {
reinhardt_query::prelude::ForeignKeyAction::Restrict => ForeignKeyAction::Restrict,
reinhardt_query::prelude::ForeignKeyAction::Cascade => ForeignKeyAction::Cascade,
reinhardt_query::prelude::ForeignKeyAction::SetNull => ForeignKeyAction::SetNull,
reinhardt_query::prelude::ForeignKeyAction::NoAction => ForeignKeyAction::NoAction,
reinhardt_query::prelude::ForeignKeyAction::SetDefault => ForeignKeyAction::SetDefault,
_ => ForeignKeyAction::NoAction,
}
}
}
pub use crate::naming::to_snake_case;
pub fn to_pascal_case(name: &str) -> String {
name.split(['_', '.', '-', ' '])
.filter(|word| !word.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
#[derive(Debug, Clone, PartialEq)]
pub struct ForeignKeyInfo {
pub referenced_table: String,
pub referenced_column: String,
pub on_delete: ForeignKeyAction,
pub on_update: ForeignKeyAction,
}
#[derive(Debug, Clone)]
pub struct FieldState {
pub name: String,
pub field_type: super::FieldType,
pub nullable: bool,
pub params: std::collections::HashMap<String, String>,
pub foreign_key: Option<ForeignKeyInfo>,
}
impl FieldState {
pub fn new(name: impl Into<String>, field_type: super::FieldType, nullable: bool) -> Self {
Self {
name: name.into(),
field_type,
nullable,
params: std::collections::HashMap::new(),
foreign_key: None,
}
}
pub fn with_foreign_key(
name: impl Into<String>,
field_type: super::FieldType,
nullable: bool,
foreign_key: ForeignKeyInfo,
) -> Self {
Self {
name: name.into(),
field_type,
nullable,
params: std::collections::HashMap::new(),
foreign_key: Some(foreign_key),
}
}
}
#[derive(Debug, Clone)]
pub struct ModelState {
pub app_label: String,
pub name: String,
pub table_name: String,
pub fields: std::collections::BTreeMap<String, FieldState>,
pub options: std::collections::HashMap<String, String>,
pub base_model: Option<String>,
pub inheritance_type: Option<String>,
pub discriminator_column: Option<String>,
pub indexes: Vec<IndexDefinition>,
pub constraints: Vec<ConstraintDefinition>,
pub many_to_many_fields: Vec<ManyToManyMetadata>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IndexDefinition {
pub name: String,
pub fields: Vec<String>,
pub unique: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConstraintDefinition {
pub name: String,
pub constraint_type: String,
pub fields: Vec<String>,
pub expression: Option<String>,
pub foreign_key_info: Option<ForeignKeyConstraintInfo>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ForeignKeyConstraintInfo {
pub referenced_table: String,
pub referenced_columns: Vec<String>,
pub on_delete: ForeignKeyAction,
pub on_update: ForeignKeyAction,
}
fn is_single_field_unique(c: &ConstraintDefinition) -> bool {
c.constraint_type.eq_ignore_ascii_case("unique") && c.fields.len() == 1
}
fn parse_single_column_unique(constraint_sql: &str) -> Option<&str> {
let after_unique = constraint_sql.split(" UNIQUE (").nth(1)?;
let close = after_unique.find(')')?;
let body = after_unique[..close].trim();
if body.contains(',') || body.is_empty() {
return None;
}
Some(body)
}
impl ConstraintDefinition {
pub fn to_constraint(&self) -> super::operations::Constraint {
match self.constraint_type.as_str() {
"unique" => super::operations::Constraint::Unique {
name: self.name.clone(),
columns: self.fields.clone(),
},
"check" => super::operations::Constraint::Check {
name: self.name.clone(),
expression: self.expression.clone().unwrap_or_default(),
},
"foreign_key" => {
if let Some(fk_info) = &self.foreign_key_info {
super::operations::Constraint::ForeignKey {
name: self.name.clone(),
columns: self.fields.clone(),
referenced_table: fk_info.referenced_table.clone(),
referenced_columns: fk_info.referenced_columns.clone(),
on_delete: fk_info.on_delete,
on_update: fk_info.on_update,
deferrable: None,
}
} else {
super::operations::Constraint::ForeignKey {
name: self.name.clone(),
columns: self.fields.clone(),
referenced_table: String::new(),
referenced_columns: vec!["id".to_string()],
on_delete: ForeignKeyAction::Cascade,
on_update: ForeignKeyAction::Cascade,
deferrable: None,
}
}
}
"one_to_one" => {
if let Some(fk_info) = &self.foreign_key_info {
super::operations::Constraint::OneToOne {
name: self.name.clone(),
column: self.fields.first().cloned().unwrap_or_default(),
referenced_table: fk_info.referenced_table.clone(),
referenced_column: fk_info
.referenced_columns
.first()
.cloned()
.unwrap_or_else(|| "id".to_string()),
on_delete: fk_info.on_delete,
on_update: fk_info.on_update,
deferrable: None,
}
} else {
super::operations::Constraint::OneToOne {
name: self.name.clone(),
column: self.fields.first().cloned().unwrap_or_default(),
referenced_table: String::new(),
referenced_column: "id".to_string(),
on_delete: ForeignKeyAction::Cascade,
on_update: ForeignKeyAction::Cascade,
deferrable: None,
}
}
}
_ => {
super::operations::Constraint::Check {
name: self.name.clone(),
expression: self.expression.clone().unwrap_or_default(),
}
}
}
}
}
impl ModelState {
pub fn new(app_label: impl Into<String>, name: impl Into<String>) -> Self {
let name_str = name.into();
let table_name = to_snake_case(&name_str);
Self {
app_label: app_label.into(),
name: name_str,
table_name,
fields: std::collections::BTreeMap::new(),
options: std::collections::HashMap::new(),
base_model: None,
inheritance_type: None,
discriminator_column: None,
indexes: Vec::new(),
constraints: Vec::new(),
many_to_many_fields: Vec::new(),
}
}
pub fn add_field(&mut self, field: FieldState) {
self.fields.insert(field.name.clone(), field);
}
pub fn get_field(&self, name: &str) -> Option<&FieldState> {
self.fields.get(name)
}
pub fn has_field(&self, name: &str) -> bool {
self.fields.contains_key(name)
}
pub fn rename_field(&mut self, old_name: &str, new_name: String) {
if let Some(mut field) = self.fields.remove(old_name) {
field.name = new_name.clone();
self.fields.insert(new_name, field);
}
}
pub fn add_constraint(&mut self, constraint: ConstraintDefinition) {
self.constraints.push(constraint);
}
pub fn add_foreign_key_constraint_from_field(&mut self, field_name: &str) {
if let Some(field) = self.fields.get(field_name)
&& let Some(ref fk_info) = field.foreign_key
{
let constraint = ConstraintDefinition {
name: format!("fk_{}_{}", self.table_name, field_name),
constraint_type: "foreign_key".to_string(),
fields: vec![field_name.to_string()],
expression: None,
foreign_key_info: Some(ForeignKeyConstraintInfo {
referenced_table: fk_info.referenced_table.clone(),
referenced_columns: vec![fk_info.referenced_column.clone()],
on_delete: fk_info.on_delete,
on_update: fk_info.on_update,
}),
};
self.add_constraint(constraint);
}
}
}
#[derive(Debug, Clone)]
pub struct ProjectState {
pub models: std::collections::BTreeMap<(String, String), ModelState>,
}
impl Default for ProjectState {
fn default() -> Self {
Self::new()
}
}
impl ProjectState {
pub fn to_database_schema(&self) -> super::schema_diff::DatabaseSchema {
let mut tables = BTreeMap::new();
for ((app_label, model_name), model_state) in &self.models {
let mut columns = BTreeMap::new();
for (field_name, field_state) in &model_state.fields {
let data_type = field_state.field_type.clone();
let nullable = field_state.nullable;
let primary_key = field_state
.params
.get("primary_key")
.is_some_and(|s| s == "true");
let auto_increment = field_state
.params
.get("auto_increment")
.is_some_and(|s| s == "true");
let default = field_state.params.get("default").cloned();
columns.insert(
field_name.clone(),
super::schema_diff::ColumnSchema {
name: field_name.clone(),
data_type,
nullable,
default,
primary_key,
auto_increment,
},
);
}
let constraints: Vec<super::schema_diff::ConstraintSchema> = model_state
.constraints
.iter()
.map(|c| super::schema_diff::ConstraintSchema {
name: c.name.clone(),
constraint_type: c.constraint_type.clone(),
definition: c.fields.join(", "),
foreign_key_info: None,
})
.collect();
let indexes: Vec<super::schema_diff::IndexSchema> = model_state
.indexes
.iter()
.map(|idx| super::schema_diff::IndexSchema {
name: idx.name.clone(),
columns: idx.fields.clone(),
unique: idx.unique,
})
.collect();
let table_key = format!("{}_{}", app_label, model_name.to_lowercase());
tables.insert(
table_key,
super::schema_diff::TableSchema {
name: model_state.table_name.clone(),
columns,
indexes,
constraints,
},
);
}
super::schema_diff::DatabaseSchema { tables }
}
pub fn to_database_schema_for_app(
&self,
app_label: &str,
) -> super::schema_diff::DatabaseSchema {
let mut tables = BTreeMap::new();
for ((this_app_label, model_name), model_state) in &self.models {
if this_app_label == app_label {
let mut columns = BTreeMap::new();
for (field_name, field_state) in &model_state.fields {
let data_type = field_state.field_type.clone();
let nullable = field_state.nullable;
let primary_key = field_state
.params
.get("primary_key")
.is_some_and(|s| s == "true");
let auto_increment = field_state
.params
.get("auto_increment")
.is_some_and(|s| s == "true");
let default = field_state.params.get("default").cloned();
columns.insert(
field_name.clone(),
super::schema_diff::ColumnSchema {
name: field_name.clone(),
data_type,
nullable,
default,
primary_key,
auto_increment,
},
);
}
let constraints: Vec<super::schema_diff::ConstraintSchema> = model_state
.constraints
.iter()
.map(|c| super::schema_diff::ConstraintSchema {
name: c.name.clone(),
constraint_type: c.constraint_type.clone(),
definition: c.fields.join(", "),
foreign_key_info: None,
})
.collect();
let indexes: Vec<super::schema_diff::IndexSchema> = model_state
.indexes
.iter()
.map(|idx| super::schema_diff::IndexSchema {
name: idx.name.clone(),
columns: idx.fields.clone(),
unique: idx.unique,
})
.collect();
let table_key = format!("{}_{}", this_app_label, model_name.to_lowercase());
tables.insert(
table_key,
super::schema_diff::TableSchema {
name: model_state.table_name.clone(),
columns,
indexes,
constraints,
},
);
}
}
super::schema_diff::DatabaseSchema { tables }
}
pub fn new() -> Self {
Self {
models: std::collections::BTreeMap::new(),
}
}
pub fn add_model(&mut self, model: ModelState) {
let key = (model.app_label.clone(), model.name.clone());
self.models.insert(key, model);
}
pub fn get_model(&self, app_label: &str, model_name: &str) -> Option<&ModelState> {
self.models
.get(&(app_label.to_string(), model_name.to_string()))
}
pub fn get_model_mut(&mut self, app_label: &str, model_name: &str) -> Option<&mut ModelState> {
self.models
.get_mut(&(app_label.to_string(), model_name.to_string()))
}
fn get_primary_key_type(&self, app_label: &str, model_name: &str) -> super::FieldType {
if let Some(model_state) = self.get_model(app_label, model_name) {
if let Some((_, id_field)) = model_state
.fields
.iter()
.find(|(name, _)| name.as_str() == "id")
{
return id_field.field_type.clone();
}
if let Some((_, pk_field)) = model_state
.fields
.iter()
.find(|(_, f)| f.params.get("primary_key").map(String::as_str) == Some("true"))
{
return pk_field.field_type.clone();
}
}
if let Some(model_meta) =
super::model_registry::global_registry().get_model(app_label, model_name)
{
if let Some(id_field) = model_meta.fields.get("id") {
return id_field.field_type.clone();
}
for field_meta in model_meta.fields.values() {
if field_meta.params.get("primary_key").map(String::as_str) == Some("true") {
return field_meta.field_type.clone();
}
}
}
super::FieldType::Uuid
}
pub fn get_model_by_table_name(
&self,
app_label: &str,
table_name: &str,
) -> Option<&ModelState> {
self.models
.values()
.find(|model| model.app_label == app_label && model.table_name == table_name)
}
pub fn filter_by_app(&self, app_label: &str) -> Self {
let mut filtered = Self::new();
for ((app, _model_name), model_state) in &self.models {
if app == app_label {
filtered.add_model(model_state.clone());
}
}
filtered
}
pub fn remove_model(&mut self, app_label: &str, model_name: &str) -> Option<ModelState> {
self.models
.remove(&(app_label.to_string(), model_name.to_string()))
}
pub fn rename_model(&mut self, app_label: &str, old_name: &str, new_name: String) {
if let Some(mut model) = self
.models
.remove(&(app_label.to_string(), old_name.to_string()))
{
model.name = new_name.clone();
self.models.insert((app_label.to_string(), new_name), model);
}
}
pub fn from_global_registry() -> Self {
use super::model_registry::global_registry;
let registry = global_registry();
let models_metadata = registry.get_models();
let mut state = ProjectState::new();
let mut intermediate_tables = Vec::new();
for metadata in &models_metadata {
let model_state = metadata.to_model_state();
state.add_model(model_state);
}
for metadata in &models_metadata {
for m2m in &metadata.many_to_many_fields {
let intermediate_table = state.create_intermediate_table_for_m2m(
&metadata.app_label,
&metadata.model_name,
&metadata.table_name,
m2m,
);
intermediate_tables.push(intermediate_table);
}
}
for table in intermediate_tables {
state.add_model(table);
}
state
}
fn create_intermediate_table_for_m2m(
&self,
source_app_label: &str,
source_model_name: &str,
source_table_name: &str,
m2m: &super::model_registry::ManyToManyMetadata,
) -> ModelState {
let table_name = m2m.through.clone().unwrap_or_else(|| {
crate::m2m_naming::default_through_table(source_table_name, &m2m.field_name)
});
let model_name = format!("{}{}", source_model_name, to_pascal_case(&m2m.field_name));
let mut model_state = ModelState::new(source_app_label, &model_name);
model_state.table_name = table_name.clone();
let mut id_field = FieldState::new("id".to_string(), super::FieldType::Integer, false);
id_field
.params
.insert("primary_key".to_string(), "true".to_string());
id_field
.params
.insert("auto_increment".to_string(), "true".to_string());
model_state.add_field(id_field);
let source_pk_type = self.get_primary_key_type(source_app_label, source_model_name);
let (target_app, target_model) = if m2m.to_model.contains('.') {
let parts: Vec<&str> = m2m.to_model.split('.').collect();
(parts[0], parts[1])
} else {
(source_app_label, m2m.to_model.as_str())
};
let target_pk_type = self.get_primary_key_type(target_app, target_model);
let target_table_name = self
.get_model(target_app, target_model)
.map(|m| m.table_name.clone())
.unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(target_model)));
let (default_source_col, default_target_col) =
crate::m2m_naming::default_m2m_columns(source_table_name, &target_table_name);
let source_field_name = m2m.source_field.clone().unwrap_or(default_source_col);
let target_field_name = m2m.target_field.clone().unwrap_or(default_target_col);
let mut from_field =
FieldState::new(source_field_name.clone(), source_pk_type.clone(), false);
from_field
.params
.insert("not_null".to_string(), "true".to_string());
from_field.foreign_key = Some(ForeignKeyInfo {
referenced_table: source_table_name.to_string(),
referenced_column: "id".to_string(),
on_delete: ForeignKeyAction::Cascade,
on_update: ForeignKeyAction::Cascade,
});
model_state.add_field(from_field);
let mut to_field = FieldState::new(target_field_name.clone(), target_pk_type, false);
to_field
.params
.insert("not_null".to_string(), "true".to_string());
to_field.foreign_key = Some(ForeignKeyInfo {
referenced_table: target_table_name,
referenced_column: "id".to_string(),
on_delete: ForeignKeyAction::Cascade,
on_update: ForeignKeyAction::Cascade,
});
model_state.add_field(to_field);
model_state.add_foreign_key_constraint_from_field(&source_field_name);
model_state.add_foreign_key_constraint_from_field(&target_field_name);
let unique_constraint = ConstraintDefinition {
name: format!("{}_unique", table_name),
constraint_type: "unique".to_string(),
fields: vec![source_field_name, target_field_name],
expression: None,
foreign_key_info: None,
};
model_state.constraints.push(unique_constraint);
model_state
}
pub fn from_migrations(migrations: &[super::migration::Migration]) -> Self {
let mut state = Self::new();
for migration in migrations {
state.apply_migration_operations(&migration.operations, &migration.app_label);
}
state
}
pub fn apply_migration_operations(
&mut self,
operations: &[super::operations::Operation],
app_label: &str,
) {
use super::operations::Operation;
for op in operations {
match op {
Operation::CreateTable { name, columns, .. } => {
let model_name = Self::table_name_to_model_name(name, app_label);
let mut model = ModelState::new(app_label, model_name);
model.table_name = name.to_string();
for col in columns {
let field = self.column_def_to_field_state(col);
model.add_field(field);
}
self.add_model(model);
}
Operation::DropTable { name } => {
let keys_to_remove: Vec<_> = self
.models
.iter()
.filter(|(_, model)| model.table_name == *name)
.map(|(key, _)| key.clone())
.collect();
for key in keys_to_remove {
self.models.remove(&key);
}
}
Operation::AddColumn { table, column, .. } => {
let field = self.column_def_to_field_state(column);
if let Some(model) = self.find_model_by_table_mut(table) {
model.add_field(field);
}
}
Operation::DropColumn { table, column } => {
if let Some(model) = self.find_model_by_table_mut(table) {
model.fields.remove(column);
}
}
Operation::AlterColumn {
table,
column,
new_definition,
..
} => {
let new_field = self.column_def_to_field_state(new_definition);
let mut updated_field = new_field;
updated_field.name = column.to_string();
if let Some(model) = self.find_model_by_table_mut(table) {
model.fields.insert(column.to_string(), updated_field);
} else {
let model_name = Self::table_name_to_model_name(table, app_label);
let mut model = ModelState::new(app_label, model_name);
model.table_name = table.to_string();
model.add_field(updated_field);
self.add_model(model);
}
}
Operation::RenameTable { old_name, new_name } => {
if let Some(model) = self.find_model_by_table_mut(old_name) {
model.table_name = new_name.to_string();
}
}
Operation::RenameColumn {
table,
old_name,
new_name,
} => {
if let Some(model) = self.find_model_by_table_mut(table) {
model.rename_field(old_name, new_name.to_string());
}
}
_ => {
}
}
}
}
pub fn find_model_by_table(&self, table_name: &str) -> Option<&ModelState> {
self.models
.values()
.find(|model| model.table_name == table_name)
}
pub fn find_model_by_table_mut(&mut self, table_name: &str) -> Option<&mut ModelState> {
self.models
.values_mut()
.find(|model| model.table_name == table_name)
}
fn table_name_to_model_name(table_name: &str, app_label: &str) -> String {
let prefix = format!("{}_", app_label);
let name_without_prefix = if table_name.starts_with(&prefix) {
&table_name[prefix.len()..]
} else {
table_name
};
name_without_prefix
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
fn column_def_to_field_state(&self, col: &super::operations::ColumnDefinition) -> FieldState {
let mut params = std::collections::HashMap::new();
if col.primary_key {
params.insert("primary_key".to_string(), "true".to_string());
}
if col.auto_increment {
params.insert("auto_increment".to_string(), "true".to_string());
}
if col.unique {
params.insert("unique".to_string(), "true".to_string());
}
if let Some(default) = &col.default {
params.insert("default".to_string(), default.to_string());
}
FieldState {
name: col.name.to_string(),
field_type: col.type_definition.clone(),
nullable: !col.not_null,
params,
foreign_key: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SimilarityConfig {
model_threshold: f64,
field_threshold: f64,
jaro_winkler_weight: f64,
levenshtein_weight: f64,
}
impl SimilarityConfig {
pub fn new(model_threshold: f64, field_threshold: f64) -> Result<Self, String> {
Self::with_weights(model_threshold, field_threshold, 0.7, 0.3)
}
pub fn with_weights(
model_threshold: f64,
field_threshold: f64,
jaro_winkler_weight: f64,
levenshtein_weight: f64,
) -> Result<Self, String> {
if !(0.45..=0.95).contains(&model_threshold) {
return Err(format!(
"model_threshold must be between 0.45 and 0.95, got {}",
model_threshold
));
}
if !(0.45..=0.95).contains(&field_threshold) {
return Err(format!(
"field_threshold must be between 0.45 and 0.95, got {}",
field_threshold
));
}
if !(0.0..=1.0).contains(&jaro_winkler_weight) {
return Err(format!(
"jaro_winkler_weight must be between 0.0 and 1.0, got {}",
jaro_winkler_weight
));
}
if !(0.0..=1.0).contains(&levenshtein_weight) {
return Err(format!(
"levenshtein_weight must be between 0.0 and 1.0, got {}",
levenshtein_weight
));
}
let weight_sum = jaro_winkler_weight + levenshtein_weight;
if (weight_sum - 1.0).abs() > 0.01 {
return Err(format!(
"jaro_winkler_weight + levenshtein_weight must sum to 1.0, got {} + {} = {}",
jaro_winkler_weight, levenshtein_weight, weight_sum
));
}
Ok(Self {
model_threshold,
field_threshold,
jaro_winkler_weight,
levenshtein_weight,
})
}
pub fn model_threshold(&self) -> f64 {
self.model_threshold
}
pub fn field_threshold(&self) -> f64 {
self.field_threshold
}
}
impl Default for SimilarityConfig {
fn default() -> Self {
Self {
model_threshold: 0.7,
field_threshold: 0.8,
jaro_winkler_weight: 0.7,
levenshtein_weight: 0.3,
}
}
}
pub struct MigrationAutodetector {
from_state: ProjectState,
to_state: ProjectState,
similarity_config: SimilarityConfig,
}
type MovedModelInfo = (String, String, String, bool, Option<String>, Option<String>);
type ModelMatchResult = ((String, String), (String, String), f64);
#[derive(Debug, Clone, Default)]
pub struct DetectedChanges {
pub created_models: Vec<(String, String)>,
pub deleted_models: Vec<(String, String)>,
pub added_fields: Vec<(String, String, String)>,
pub removed_fields: Vec<(String, String, String)>,
pub altered_fields: Vec<(String, String, String)>,
pub renamed_models: Vec<(String, String, String)>,
pub moved_models: Vec<MovedModelInfo>,
pub renamed_fields: Vec<(String, String, String, String)>,
pub added_indexes: Vec<(String, String, IndexDefinition)>,
pub removed_indexes: Vec<(String, String, String)>,
pub added_constraints: Vec<(String, String, ConstraintDefinition)>,
pub removed_constraints: Vec<(String, String, String)>,
pub added_composite_primary_keys: Vec<(String, String, ConstraintDefinition)>,
pub removed_composite_primary_keys: Vec<(String, String, String)>,
pub auto_increment_resets: Vec<(String, String, String, i64)>,
pub model_dependencies: std::collections::BTreeMap<(String, String), Vec<(String, String)>>,
pub created_many_to_many: Vec<(String, String, String, ManyToManyMetadata)>,
}
impl DetectedChanges {
pub fn order_models_by_dependency(&self) -> Vec<(String, String)> {
use std::collections::{HashMap, HashSet, VecDeque};
let mut in_degree: HashMap<(String, String), usize> = HashMap::new();
let mut all_models: HashSet<(String, String)> = HashSet::new();
for model in &self.created_models {
all_models.insert(model.clone());
in_degree.entry(model.clone()).or_insert(0);
}
for model in &self.moved_models {
let model_key = (model.1.clone(), model.2.clone()); all_models.insert(model_key.clone());
in_degree.entry(model_key).or_insert(0);
}
for (dependent, dependencies) in &self.model_dependencies {
for dependency in dependencies {
all_models.insert(dependency.clone());
in_degree.entry(dependency.clone()).or_insert(0);
*in_degree.entry(dependent.clone()).or_insert(0) += 1;
}
}
let mut queue: VecDeque<(String, String)> = VecDeque::new();
for model in &all_models {
if in_degree.get(model).copied().unwrap_or(0) == 0 {
queue.push_back(model.clone());
}
}
let mut ordered = Vec::new();
while let Some(model) = queue.pop_front() {
ordered.push(model.clone());
for (dependent, dependencies) in &self.model_dependencies {
if dependencies.contains(&model)
&& let Some(degree) = in_degree.get_mut(dependent)
{
*degree -= 1;
if *degree == 0 {
queue.push_back(dependent.clone());
}
}
}
}
if ordered.len() < all_models.len() {
let unordered_models: Vec<_> = all_models
.iter()
.filter(|model| !ordered.contains(model))
.map(|(app, name)| format!("{}.{}", app, name))
.collect();
eprintln!(
"⚠️ Warning: Circular dependency detected in models: [{}]",
unordered_models.join(", ")
);
eprintln!(
" Falling back to original order. Migration operations may need manual reordering."
);
all_models.into_iter().collect()
} else {
ordered
}
}
pub fn check_circular_dependencies(&self) -> Result<(), Vec<(String, String)>> {
use std::collections::HashSet;
let mut visited: HashSet<(String, String)> = HashSet::new();
let mut rec_stack: HashSet<(String, String)> = HashSet::new();
let mut path: Vec<(String, String)> = Vec::new();
fn dfs(
model: &(String, String),
deps: &BTreeMap<(String, String), Vec<(String, String)>>,
visited: &mut HashSet<(String, String)>,
rec_stack: &mut HashSet<(String, String)>,
path: &mut Vec<(String, String)>,
) -> Option<Vec<(String, String)>> {
visited.insert(model.clone());
rec_stack.insert(model.clone());
path.push(model.clone());
if let Some(dependencies) = deps.get(model) {
for dep in dependencies {
if !visited.contains(dep) {
if let Some(cycle) = dfs(dep, deps, visited, rec_stack, path) {
return Some(cycle);
}
} else if rec_stack.contains(dep) {
let cycle_start = path.iter().position(|m| m == dep).unwrap();
return Some(path[cycle_start..].to_vec());
}
}
}
path.pop();
rec_stack.remove(model);
None
}
for model in self.model_dependencies.keys() {
if !visited.contains(model)
&& let Some(cycle) = dfs(
model,
&self.model_dependencies,
&mut visited,
&mut rec_stack,
&mut path,
) {
return Err(cycle);
}
}
Ok(())
}
pub fn remove_operations(&mut self, refs: &[OperationRef]) {
for op_ref in refs {
match op_ref {
OperationRef::RenamedModel {
app_label,
old_name,
new_name,
} => {
self.renamed_models.retain(|(app, old, new)| {
!(app == app_label && old == old_name && new == new_name)
});
}
OperationRef::MovedModel {
from_app,
to_app,
model_name,
} => {
self.moved_models.retain(|info| {
!(&info.0 == from_app && &info.1 == to_app && &info.2 == model_name)
});
}
OperationRef::AddedField {
app_label,
model_name,
field_name,
} => {
self.added_fields.retain(|(app, model, field)| {
!(app == app_label && model == model_name && field == field_name)
});
}
OperationRef::RenamedField {
app_label,
model_name,
old_name,
new_name,
} => {
self.renamed_fields.retain(|(app, model, old, new)| {
!(app == app_label
&& model == model_name
&& old == old_name && new == new_name)
});
}
OperationRef::RemovedField {
app_label,
model_name,
field_name,
} => {
self.removed_fields.retain(|(app, model, field)| {
!(app == app_label && model == model_name && field == field_name)
});
}
OperationRef::AlteredField {
app_label,
model_name,
field_name,
} => {
self.altered_fields.retain(|(app, model, field)| {
!(app == app_label && model == model_name && field == field_name)
});
}
OperationRef::CreatedModel {
app_label,
model_name,
} => {
self.created_models
.retain(|(app, model)| !(app == app_label && model == model_name));
}
OperationRef::DeletedModel {
app_label,
model_name,
} => {
self.deleted_models
.retain(|(app, model)| !(app == app_label && model == model_name));
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct ChangeHistoryEntry {
pub timestamp: std::time::SystemTime,
pub change_type: String,
pub app_label: String,
pub model_name: String,
pub field_name: Option<String>,
pub old_value: Option<String>,
pub new_value: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PatternFrequency {
pub pattern: String,
pub frequency: usize,
pub last_seen: std::time::SystemTime,
pub contexts: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ChangeTracker {
history: Vec<ChangeHistoryEntry>,
patterns: HashMap<String, PatternFrequency>,
max_history_size: usize,
}
impl ChangeTracker {
pub fn new() -> Self {
Self {
history: Vec::new(),
patterns: HashMap::new(),
max_history_size: 1000,
}
}
pub fn with_capacity(max_size: usize) -> Self {
Self {
history: Vec::with_capacity(max_size),
patterns: HashMap::new(),
max_history_size: max_size,
}
}
pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
let entry = ChangeHistoryEntry {
timestamp: std::time::SystemTime::now(),
change_type: "RenameModel".to_string(),
app_label: app_label.to_string(),
model_name: new_name.to_string(),
field_name: None,
old_value: Some(old_name.to_string()),
new_value: Some(new_name.to_string()),
};
self.add_entry(entry);
self.update_pattern(
&format!("RenameModel:{}->{}", old_name, new_name),
app_label,
);
}
pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
let entry = ChangeHistoryEntry {
timestamp: std::time::SystemTime::now(),
change_type: "MoveModel".to_string(),
app_label: to_app.to_string(),
model_name: model_name.to_string(),
field_name: None,
old_value: Some(from_app.to_string()),
new_value: Some(to_app.to_string()),
};
self.add_entry(entry);
self.update_pattern(
&format!("MoveModel:{}->{}:{}", from_app, to_app, model_name),
to_app,
);
}
pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
let entry = ChangeHistoryEntry {
timestamp: std::time::SystemTime::now(),
change_type: "AddField".to_string(),
app_label: app_label.to_string(),
model_name: model_name.to_string(),
field_name: Some(field_name.to_string()),
old_value: None,
new_value: Some(field_name.to_string()),
};
self.add_entry(entry);
self.update_pattern(
&format!("AddField:{}:{}", model_name, field_name),
app_label,
);
}
pub fn record_field_rename(
&mut self,
app_label: &str,
model_name: &str,
old_name: &str,
new_name: &str,
) {
let entry = ChangeHistoryEntry {
timestamp: std::time::SystemTime::now(),
change_type: "RenameField".to_string(),
app_label: app_label.to_string(),
model_name: model_name.to_string(),
field_name: Some(new_name.to_string()),
old_value: Some(old_name.to_string()),
new_value: Some(new_name.to_string()),
};
self.add_entry(entry);
self.update_pattern(
&format!("RenameField:{}:{}->{}", model_name, old_name, new_name),
app_label,
);
}
fn add_entry(&mut self, entry: ChangeHistoryEntry) {
self.history.push(entry);
if self.history.len() > self.max_history_size {
self.history.remove(0);
}
}
fn update_pattern(&mut self, pattern: &str, context: &str) {
self.patterns
.entry(pattern.to_string())
.and_modify(|pf| {
pf.frequency += 1;
pf.last_seen = std::time::SystemTime::now();
if !pf.contexts.contains(&context.to_string()) {
pf.contexts.push(context.to_string());
}
})
.or_insert(PatternFrequency {
pattern: pattern.to_string(),
frequency: 1,
last_seen: std::time::SystemTime::now(),
contexts: vec![context.to_string()],
});
}
pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
let mut patterns: Vec<_> = self
.patterns
.values()
.filter(|p| p.frequency >= min_frequency)
.cloned()
.collect();
patterns.sort_by(|a, b| b.frequency.cmp(&a.frequency));
patterns
}
pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
let now = std::time::SystemTime::now();
self.history
.iter()
.filter(|entry| {
now.duration_since(entry.timestamp)
.map(|d| d < duration)
.unwrap_or(false)
})
.collect()
}
pub fn analyze_cooccurrence(
&self,
window: std::time::Duration,
) -> HashMap<(String, String), usize> {
let mut cooccurrences = HashMap::new();
for i in 0..self.history.len() {
for j in (i + 1)..self.history.len() {
if let Ok(diff) = self.history[j]
.timestamp
.duration_since(self.history[i].timestamp)
&& diff <= window
{
let pattern1 = format!(
"{}:{}",
self.history[i].change_type, self.history[i].model_name
);
let pattern2 = format!(
"{}:{}",
self.history[j].change_type, self.history[j].model_name
);
let key = if pattern1 < pattern2 {
(pattern1, pattern2)
} else {
(pattern2, pattern1)
};
*cooccurrences.entry(key).or_insert(0) += 1;
}
}
}
cooccurrences
}
pub fn clear(&mut self) {
self.history.clear();
self.patterns.clear();
}
pub fn len(&self) -> usize {
self.history.len()
}
pub fn is_empty(&self) -> bool {
self.history.is_empty()
}
}
impl Default for ChangeTracker {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct PatternMatch {
pub pattern: String,
pub start: usize,
pub end: usize,
pub matched_text: String,
}
#[derive(Debug, Clone)]
pub struct PatternMatcher {
patterns: Vec<String>,
automaton: Option<aho_corasick::AhoCorasick>,
}
impl PatternMatcher {
pub fn new() -> Self {
Self {
patterns: Vec::new(),
automaton: None,
}
}
pub fn add_pattern(&mut self, pattern: &str) {
self.patterns.push(pattern.to_string());
self.automaton = None;
}
pub fn add_patterns<I, S>(&mut self, patterns: I)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for pattern in patterns {
self.patterns.push(pattern.as_ref().to_string());
}
self.automaton = None;
}
pub fn build(&mut self) -> Result<(), String> {
if self.patterns.is_empty() {
return Err("No patterns to build automaton".to_string());
}
self.automaton = Some(
aho_corasick::AhoCorasick::new(&self.patterns)
.map_err(|e| format!("Failed to build Aho-Corasick automaton: {}", e))?,
);
Ok(())
}
pub fn find_all(&self, text: &str) -> Vec<PatternMatch> {
let Some(ref automaton) = self.automaton else {
return Vec::new();
};
automaton
.find_iter(text)
.map(|mat| PatternMatch {
pattern: self.patterns[mat.pattern().as_usize()].clone(),
start: mat.start(),
end: mat.end(),
matched_text: text[mat.start()..mat.end()].to_string(),
})
.collect()
}
pub fn contains_any(&self, text: &str) -> bool {
self.automaton
.as_ref()
.map(|ac| ac.is_match(text))
.unwrap_or(false)
}
pub fn find_first(&self, text: &str) -> Option<PatternMatch> {
let automaton = self.automaton.as_ref()?;
let mat = automaton.find(text)?;
Some(PatternMatch {
pattern: self.patterns[mat.pattern().as_usize()].clone(),
start: mat.start(),
end: mat.end(),
matched_text: text[mat.start()..mat.end()].to_string(),
})
}
pub fn replace_all(&self, text: &str, replacements: &HashMap<String, String>) -> String {
let Some(ref automaton) = self.automaton else {
return text.to_string();
};
let mut result = String::new();
let mut last_end = 0;
for mat in automaton.find_iter(text) {
result.push_str(&text[last_end..mat.start()]);
let pattern = &self.patterns[mat.pattern().as_usize()];
if let Some(replacement) = replacements.get(pattern) {
result.push_str(replacement);
} else {
result.push_str(&text[mat.start()..mat.end()]);
}
last_end = mat.end();
}
result.push_str(&text[last_end..]);
result
}
pub fn patterns(&self) -> &[String] {
&self.patterns
}
pub fn clear(&mut self) {
self.patterns.clear();
self.automaton = None;
}
pub fn is_built(&self) -> bool {
self.automaton.is_some()
}
}
impl Default for PatternMatcher {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RuleCondition {
ModelRename {
from_pattern: String,
to_pattern: String,
},
ModelMove {
app_pattern: String,
},
FieldAddition {
field_name_pattern: String,
},
FieldRename {
from_pattern: String,
to_pattern: String,
},
MultipleModelRenames {
min_count: usize,
},
MultipleFieldAdditions {
model_pattern: String,
min_count: usize,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum OperationRef {
RenamedModel {
app_label: String,
old_name: String,
new_name: String,
},
MovedModel {
from_app: String,
to_app: String,
model_name: String,
},
AddedField {
app_label: String,
model_name: String,
field_name: String,
},
RenamedField {
app_label: String,
model_name: String,
old_name: String,
new_name: String,
},
RemovedField {
app_label: String,
model_name: String,
field_name: String,
},
AlteredField {
app_label: String,
model_name: String,
field_name: String,
},
CreatedModel {
app_label: String,
model_name: String,
},
DeletedModel {
app_label: String,
model_name: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct InferredIntent {
pub intent_type: String,
pub confidence: f64,
pub description: String,
pub evidence: Vec<String>,
pub related_operations: Vec<OperationRef>,
}
#[derive(Debug, Clone)]
pub struct InferenceRule {
pub name: String,
pub conditions: Vec<RuleCondition>,
pub optional_conditions: Vec<RuleCondition>,
pub intent_type: String,
pub base_confidence: f64,
pub confidence_boost_per_optional: f64,
}
#[derive(Debug, Clone)]
pub struct InferenceEngine {
rules: Vec<InferenceRule>,
change_tracker: ChangeTracker,
}
impl Default for InferenceEngine {
fn default() -> Self {
Self::new()
}
}
impl InferenceEngine {
pub fn new() -> Self {
Self {
rules: Vec::new(),
change_tracker: ChangeTracker::new(),
}
}
pub fn add_rule(&mut self, rule: InferenceRule) {
self.rules.push(rule);
}
pub fn add_default_rules(&mut self) {
self.add_rule(InferenceRule {
name: "model_refactoring".to_string(),
conditions: vec![RuleCondition::ModelRename {
from_pattern: ".*".to_string(),
to_pattern: ".*".to_string(),
}],
optional_conditions: vec![RuleCondition::MultipleModelRenames { min_count: 2 }],
intent_type: "Refactoring: Model rename".to_string(),
base_confidence: 0.7,
confidence_boost_per_optional: 0.1,
});
self.add_rule(InferenceRule {
name: "add_timestamp_tracking".to_string(),
conditions: vec![RuleCondition::FieldAddition {
field_name_pattern: "created_at".to_string(),
}],
optional_conditions: vec![RuleCondition::FieldAddition {
field_name_pattern: "updated_at".to_string(),
}],
intent_type: "Add timestamp tracking".to_string(),
base_confidence: 0.8,
confidence_boost_per_optional: 0.15,
});
self.add_rule(InferenceRule {
name: "cross_app_move".to_string(),
conditions: vec![RuleCondition::ModelMove {
app_pattern: ".*".to_string(),
}],
optional_conditions: vec![],
intent_type: "Cross-app model organization".to_string(),
base_confidence: 0.75,
confidence_boost_per_optional: 0.0,
});
self.add_rule(InferenceRule {
name: "field_refactoring".to_string(),
conditions: vec![RuleCondition::FieldRename {
from_pattern: ".*".to_string(),
to_pattern: ".*".to_string(),
}],
optional_conditions: vec![RuleCondition::MultipleFieldAdditions {
model_pattern: ".*".to_string(),
min_count: 2,
}],
intent_type: "Refactoring: Field rename".to_string(),
base_confidence: 0.65,
confidence_boost_per_optional: 0.1,
});
self.add_rule(InferenceRule {
name: "model_normalization".to_string(),
conditions: vec![RuleCondition::MultipleFieldAdditions {
model_pattern: ".*".to_string(),
min_count: 3,
}],
optional_conditions: vec![],
intent_type: "Schema normalization".to_string(),
base_confidence: 0.6,
confidence_boost_per_optional: 0.0,
});
}
fn matches_pattern(value: &str, pattern: &str) -> bool {
if pattern == ".*" {
return true;
}
if value == pattern {
return true;
}
if let Ok(re) = Regex::new(pattern) {
re.is_match(value)
} else {
false
}
}
pub fn rules(&self) -> &[InferenceRule] {
&self.rules
}
pub fn infer_intents(
&self,
model_renames: &[(String, String, String, String)], model_moves: &[(String, String, String, String)], field_additions: &[(String, String, String)], field_renames: &[(String, String, String, String)], ) -> Vec<InferredIntent> {
let mut intents = Vec::new();
for rule in &self.rules {
let mut matches_required = true;
let mut optional_matches = 0;
let mut evidence = Vec::new();
for condition in &rule.conditions {
match condition {
RuleCondition::ModelRename {
from_pattern,
to_pattern,
} => {
if model_renames.is_empty() {
matches_required = false;
break;
}
let mut matched = false;
for (from_app, from_model, to_app, to_model) in model_renames {
let from_name = format!("{}.{}", from_app, from_model);
let to_name = format!("{}.{}", to_app, to_model);
if Self::matches_pattern(&from_name, from_pattern)
&& Self::matches_pattern(&to_name, to_pattern)
{
evidence.push(format!(
"Model renamed: {} → {} (pattern: {} → {})",
from_name, to_name, from_pattern, to_pattern
));
matched = true;
break;
}
}
if !matched {
matches_required = false;
break;
}
}
RuleCondition::ModelMove { app_pattern } => {
if model_moves.is_empty() {
matches_required = false;
break;
}
let mut matched = false;
for (from_app, from_model, to_app, to_model) in model_moves {
if Self::matches_pattern(to_app, app_pattern) {
evidence.push(format!(
"Model moved: {}.{} → {}.{} (app pattern: {})",
from_app, from_model, to_app, to_model, app_pattern
));
matched = true;
break;
}
}
if !matched {
matches_required = false;
break;
}
}
RuleCondition::FieldAddition { field_name_pattern } => {
let matching_fields: Vec<_> = field_additions
.iter()
.filter(|(_, _, field)| {
Self::matches_pattern(field, field_name_pattern)
})
.collect();
if matching_fields.is_empty() {
matches_required = false;
break;
}
evidence.push(format!(
"Field added: {}.{}.{} (pattern: {})",
matching_fields[0].0,
matching_fields[0].1,
matching_fields[0].2,
field_name_pattern
));
}
RuleCondition::FieldRename {
from_pattern,
to_pattern,
} => {
if field_renames.is_empty() {
matches_required = false;
break;
}
let mut matched = false;
for (app, model, from_field, to_field) in field_renames {
if Self::matches_pattern(from_field, from_pattern)
&& Self::matches_pattern(to_field, to_pattern)
{
evidence.push(format!(
"Field renamed: {}.{}.{} → {} (pattern: {} → {})",
app, model, from_field, to_field, from_pattern, to_pattern
));
matched = true;
break;
}
}
if !matched {
matches_required = false;
break;
}
}
RuleCondition::MultipleModelRenames { min_count } => {
if model_renames.len() < *min_count {
matches_required = false;
break;
}
evidence.push(format!("Multiple model renames: {}", model_renames.len()));
}
RuleCondition::MultipleFieldAdditions {
model_pattern,
min_count,
} => {
let count = field_additions
.iter()
.filter(|(_, model, _)| Self::matches_pattern(model, model_pattern))
.count();
if count < *min_count {
matches_required = false;
break;
}
evidence.push(format!(
"Multiple field additions: {} (pattern: {}, min: {})",
count, model_pattern, min_count
));
}
}
}
if !matches_required {
continue;
}
for condition in &rule.optional_conditions {
match condition {
RuleCondition::FieldAddition { field_name_pattern } => {
if field_additions
.iter()
.any(|(_, _, field)| field.contains(field_name_pattern.as_str()))
{
optional_matches += 1;
evidence.push(format!("Optional field added: {}", field_name_pattern));
}
}
RuleCondition::MultipleModelRenames { min_count } => {
if model_renames.len() >= *min_count {
optional_matches += 1;
evidence.push(format!("Multiple renames: {}", model_renames.len()));
}
}
_ => {}
}
}
let confidence = rule.base_confidence
+ (optional_matches as f64 * rule.confidence_boost_per_optional);
let confidence = confidence.min(1.0);
intents.push(InferredIntent {
intent_type: rule.intent_type.clone(),
confidence,
description: format!("Detected: {}", rule.name),
evidence,
related_operations: Vec::new(),
});
}
intents.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
intents
}
pub fn infer_from_detected_changes(&self, changes: &DetectedChanges) -> Vec<InferredIntent> {
let model_renames: Vec<(String, String, String, String)> = changes
.renamed_models
.iter()
.map(|(app, old_name, new_name)| {
(app.clone(), old_name.clone(), app.clone(), new_name.clone())
})
.collect();
let model_moves: Vec<(String, String, String, String)> = changes
.moved_models
.iter()
.map(|(from_app, to_app, model, _, _, _)| {
(
from_app.clone(),
model.clone(),
to_app.clone(),
model.clone(),
)
})
.collect();
let field_additions: Vec<(String, String, String)> = changes
.added_fields
.iter()
.map(|(app, model, field)| (app.clone(), model.clone(), field.clone()))
.collect();
let field_renames: Vec<(String, String, String, String)> = changes
.renamed_fields
.iter()
.map(|(app, model, old_name, new_name)| {
(
app.clone(),
model.clone(),
old_name.clone(),
new_name.clone(),
)
})
.collect();
let mut intents = self.infer_intents(
&model_renames,
&model_moves,
&field_additions,
&field_renames,
);
for intent in &mut intents {
for evidence_str in &intent.evidence {
if evidence_str.starts_with("Model renamed:") {
for (app, old_name, new_name) in &changes.renamed_models {
intent.related_operations.push(OperationRef::RenamedModel {
app_label: app.clone(),
old_name: old_name.clone(),
new_name: new_name.clone(),
});
}
}
else if evidence_str.starts_with("Model moved:") {
for (from_app, to_app, model, _, _, _) in &changes.moved_models {
intent.related_operations.push(OperationRef::MovedModel {
from_app: from_app.clone(),
to_app: to_app.clone(),
model_name: model.clone(),
});
}
}
else if evidence_str.starts_with("Field added:") {
for (app, model, field) in &changes.added_fields {
intent.related_operations.push(OperationRef::AddedField {
app_label: app.clone(),
model_name: model.clone(),
field_name: field.clone(),
});
}
}
else if evidence_str.starts_with("Field renamed:") {
for (app, model, old_name, new_name) in &changes.renamed_fields {
intent.related_operations.push(OperationRef::RenamedField {
app_label: app.clone(),
model_name: model.clone(),
old_name: old_name.clone(),
new_name: new_name.clone(),
});
}
}
else if evidence_str.starts_with("Multiple model renames:") {
for (app, old_name, new_name) in &changes.renamed_models {
intent.related_operations.push(OperationRef::RenamedModel {
app_label: app.clone(),
old_name: old_name.clone(),
new_name: new_name.clone(),
});
}
}
else if evidence_str.starts_with("Multiple field additions:")
|| evidence_str.starts_with("Optional field added:")
{
for (app, model, field) in &changes.added_fields {
intent.related_operations.push(OperationRef::AddedField {
app_label: app.clone(),
model_name: model.clone(),
field_name: field.clone(),
});
}
}
}
intent
.related_operations
.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
intent.related_operations.dedup();
}
intents
}
pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
self.change_tracker
.record_model_rename(app_label, old_name, new_name);
}
pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
self.change_tracker
.record_model_move(from_app, to_app, model_name);
}
pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
self.change_tracker
.record_field_addition(app_label, model_name, field_name);
}
pub fn record_field_rename(
&mut self,
app_label: &str,
model_name: &str,
old_name: &str,
new_name: &str,
) {
self.change_tracker
.record_field_rename(app_label, model_name, old_name, new_name);
}
pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
self.change_tracker.get_frequent_patterns(min_frequency)
}
pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
self.change_tracker.get_recent_changes(duration)
}
pub fn analyze_cooccurrence(
&self,
window: std::time::Duration,
) -> HashMap<(String, String), usize> {
self.change_tracker.analyze_cooccurrence(window)
}
}
pub struct MigrationPrompt {
auto_accept_threshold: f64,
theme: dialoguer::theme::ColorfulTheme,
}
impl std::fmt::Debug for MigrationPrompt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MigrationPrompt")
.field("auto_accept_threshold", &self.auto_accept_threshold)
.field("theme", &"ColorfulTheme")
.finish()
}
}
impl MigrationPrompt {
pub fn new() -> Self {
Self {
auto_accept_threshold: 0.85,
theme: dialoguer::theme::ColorfulTheme::default(),
}
}
pub fn with_threshold(threshold: f64) -> Self {
Self {
auto_accept_threshold: threshold,
theme: dialoguer::theme::ColorfulTheme::default(),
}
}
pub fn auto_accept_threshold(&self) -> f64 {
self.auto_accept_threshold
}
pub fn confirm_intent(
&self,
intent: &InferredIntent,
) -> Result<bool, Box<dyn std::error::Error>> {
if intent.confidence >= self.auto_accept_threshold {
println!(
"✓ Auto-accepting (confidence: {:.1}%): {}",
intent.confidence * 100.0,
intent.intent_type
);
return Ok(true);
}
let message = format!(
"Detected: {} (confidence: {:.1}%)\nDetails: {}\n\nAccept this change?",
intent.intent_type,
intent.confidence * 100.0,
intent.description
);
if !intent.evidence.is_empty() {
println!("\nEvidence:");
for evidence in &intent.evidence {
println!(" • {}", evidence);
}
}
dialoguer::Confirm::with_theme(&self.theme)
.with_prompt(message)
.default(true)
.interact()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
pub fn select_intent(
&self,
alternatives: &[InferredIntent],
prompt: &str,
) -> Result<Option<usize>, Box<dyn std::error::Error>> {
if alternatives.is_empty() {
return Ok(None);
}
if alternatives.len() == 1 {
let confirmed = self.confirm_intent(&alternatives[0])?;
return Ok(if confirmed { Some(0) } else { None });
}
let items: Vec<String> = alternatives
.iter()
.map(|intent| {
format!(
"{} (confidence: {:.1}%) - {}",
intent.intent_type,
intent.confidence * 100.0,
intent.description
)
})
.collect();
println!("\n{}", prompt);
println!("Multiple possibilities detected:\n");
let mut items_with_none = items.clone();
items_with_none.push("None of the above / Skip".to_string());
let selection = dialoguer::Select::with_theme(&self.theme)
.items(&items_with_none)
.default(0)
.interact()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
if selection >= items.len() {
Ok(None)
} else {
Ok(Some(selection))
}
}
pub fn multi_select_intents(
&self,
alternatives: &[InferredIntent],
prompt: &str,
) -> Result<Vec<usize>, Box<dyn std::error::Error>> {
if alternatives.is_empty() {
return Ok(Vec::new());
}
let items: Vec<String> = alternatives
.iter()
.map(|intent| {
format!(
"{} (confidence: {:.1}%) - {}",
intent.intent_type,
intent.confidence * 100.0,
intent.description
)
})
.collect();
println!("\n{}", prompt);
println!("Select all that apply:\n");
let selections = dialoguer::MultiSelect::with_theme(&self.theme)
.items(&items)
.interact()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok(selections)
}
pub fn confirm_model_rename(
&self,
from_app: &str,
from_model: &str,
to_app: &str,
to_model: &str,
confidence: f64,
) -> Result<bool, Box<dyn std::error::Error>> {
if confidence >= self.auto_accept_threshold {
println!(
"✓ Auto-accepting model rename (confidence: {:.1}%): {}.{} → {}.{}",
confidence * 100.0,
from_app,
from_model,
to_app,
to_model
);
return Ok(true);
}
let message = format!(
"Rename model from {}.{} to {}.{}?\n(confidence: {:.1}%)",
from_app,
from_model,
to_app,
to_model,
confidence * 100.0
);
dialoguer::Confirm::with_theme(&self.theme)
.with_prompt(message)
.default(true)
.interact()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
pub fn confirm_field_rename(
&self,
model: &str,
from_field: &str,
to_field: &str,
confidence: f64,
) -> Result<bool, Box<dyn std::error::Error>> {
if confidence >= self.auto_accept_threshold {
println!(
"✓ Auto-accepting field rename (confidence: {:.1}%): {}.{} → {}.{}",
confidence * 100.0,
model,
from_field,
model,
to_field
);
return Ok(true);
}
let message = format!(
"Rename field in model {}:\n {} → {}?\n(confidence: {:.1}%)",
model,
from_field,
to_field,
confidence * 100.0
);
dialoguer::Confirm::with_theme(&self.theme)
.with_prompt(message)
.default(true)
.interact()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
pub fn with_progress<F, T>(
&self,
message: &str,
total: u64,
operation: F,
) -> Result<T, Box<dyn std::error::Error>>
where
F: FnOnce(&indicatif::ProgressBar) -> Result<T, Box<dyn std::error::Error>>,
{
let pb = indicatif::ProgressBar::new(total);
pb.set_style(
indicatif::ProgressStyle::default_bar()
.template("{msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
.expect("Failed to create progress bar template")
.progress_chars("#>-"),
);
pb.set_message(message.to_string());
let result = operation(&pb)?;
pb.finish_with_message("Done");
Ok(result)
}
}
impl Default for MigrationPrompt {
fn default() -> Self {
Self::new()
}
}
pub trait InteractiveAutodetector {
fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>>;
fn apply_intents_interactive(
&self,
intents: Vec<InferredIntent>,
changes: &mut DetectedChanges,
) -> Result<(), Box<dyn std::error::Error>>;
}
impl InteractiveAutodetector for MigrationAutodetector {
fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>> {
let prompt = MigrationPrompt::new();
let mut changes = self.detect_changes();
let mut engine = InferenceEngine::new();
engine.add_default_rules();
let intents = engine.infer_from_detected_changes(&changes);
let ambiguous_intents: Vec<_> = intents
.into_iter()
.filter(|intent| intent.confidence < prompt.auto_accept_threshold)
.collect();
if !ambiguous_intents.is_empty() {
println!(
"\n⚠️ Found {} ambiguous change(s) requiring confirmation:",
ambiguous_intents.len()
);
for intent in &ambiguous_intents {
let confirmed = prompt.confirm_intent(intent)?;
if !confirmed {
println!("✗ Skipped: {}", intent.description);
if !intent.related_operations.is_empty() {
changes.remove_operations(&intent.related_operations);
println!(
" → Removed {} related operation(s) from migration",
intent.related_operations.len()
);
}
}
}
}
self.detect_model_dependencies(&mut changes);
if let Err(cycle) = changes.check_circular_dependencies() {
println!("\n⚠️ Warning: Circular dependency detected: {:?}", cycle);
let should_continue = dialoguer::Confirm::new()
.with_prompt("Continue anyway? (may require manual intervention)")
.default(false)
.interact()?;
if !should_continue {
return Err("Aborted due to circular dependency".into());
}
}
Ok(changes)
}
fn apply_intents_interactive(
&self,
intents: Vec<InferredIntent>,
_changes: &mut DetectedChanges,
) -> Result<(), Box<dyn std::error::Error>> {
let prompt = MigrationPrompt::new();
let mut high_confidence = Vec::new();
let mut medium_confidence = Vec::new();
let mut low_confidence = Vec::new();
for intent in intents {
if intent.confidence >= 0.85 {
high_confidence.push(intent);
} else if intent.confidence >= 0.65 {
medium_confidence.push(intent);
} else {
low_confidence.push(intent);
}
}
println!(
"\n✓ Auto-applying {} high-confidence change(s):",
high_confidence.len()
);
for intent in &high_confidence {
println!(
" • {} (confidence: {:.1}%)",
intent.description,
intent.confidence * 100.0
);
}
if !medium_confidence.is_empty() {
println!(
"\n⚠️ Review {} medium-confidence change(s):",
medium_confidence.len()
);
for intent in &medium_confidence {
let confirmed = prompt.confirm_intent(intent)?;
if confirmed {
println!(" ✓ Accepted: {}", intent.description);
} else {
println!(" ✗ Rejected: {}", intent.description);
}
}
}
if !low_confidence.is_empty() {
let selections = prompt.multi_select_intents(
&low_confidence,
"⚠️ Select low-confidence changes to apply:",
)?;
for idx in selections {
println!(" ✓ Accepted: {}", low_confidence[idx].description);
}
}
Ok(())
}
}
impl MigrationAutodetector {
pub fn new(from_state: ProjectState, to_state: ProjectState) -> Self {
Self {
from_state,
to_state,
similarity_config: SimilarityConfig::default(),
}
}
pub fn with_config(
from_state: ProjectState,
to_state: ProjectState,
similarity_config: SimilarityConfig,
) -> Self {
Self {
from_state,
to_state,
similarity_config,
}
}
pub fn detect_changes(&self) -> DetectedChanges {
let mut changes = DetectedChanges::default();
self.detect_created_models(&mut changes);
self.detect_deleted_models(&mut changes);
self.detect_renamed_models(&mut changes);
self.detect_added_fields(&mut changes);
self.detect_removed_fields(&mut changes);
self.detect_altered_fields(&mut changes);
self.detect_renamed_fields(&mut changes);
self.detect_added_indexes(&mut changes);
self.detect_removed_indexes(&mut changes);
self.detect_added_constraints(&mut changes);
self.detect_removed_constraints(&mut changes);
self.detect_composite_pk_changes(&mut changes);
self.detect_auto_increment_resets(&mut changes);
self.detect_created_many_to_many(&mut changes);
self.detect_model_dependencies(&mut changes);
changes.created_models.sort();
changes.deleted_models.sort();
changes.added_fields.sort();
changes.removed_fields.sort();
changes.altered_fields.sort();
changes.renamed_models.sort();
changes.renamed_fields.sort();
changes
.added_indexes
.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
changes.removed_indexes.sort();
changes
.added_constraints
.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
changes.removed_constraints.sort();
changes
.added_composite_primary_keys
.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
changes.removed_composite_primary_keys.sort();
changes.auto_increment_resets.sort();
changes
.created_many_to_many
.sort_by(|a, b| (&a.0, &a.1, &a.2).cmp(&(&b.0, &b.1, &b.2)));
changes
}
fn detect_created_models(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), to_model) in &self.to_state.models {
if self
.from_state
.get_model_by_table_name(app_label, &to_model.table_name)
.is_none()
{
changes
.created_models
.push((app_label.clone(), model_name.clone()));
}
}
}
fn detect_deleted_models(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), from_model) in &self.from_state.models {
if self
.to_state
.get_model_by_table_name(app_label, &from_model.table_name)
.is_none()
{
changes
.deleted_models
.push((app_label.clone(), model_name.clone()));
}
}
}
fn detect_added_fields(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), to_model) in &self.to_state.models {
if let Some(from_model) = self
.from_state
.get_model_by_table_name(app_label, &to_model.table_name)
{
for field_name in to_model.fields.keys() {
if !from_model.fields.contains_key(field_name) {
changes.added_fields.push((
app_label.clone(),
model_name.clone(),
field_name.clone(),
));
}
}
}
}
}
fn detect_removed_fields(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), from_model) in &self.from_state.models {
if let Some(to_model) = self
.to_state
.get_model_by_table_name(app_label, &from_model.table_name)
{
for field_name in from_model.fields.keys() {
if !to_model.fields.contains_key(field_name) {
changes.removed_fields.push((
app_label.clone(),
model_name.clone(),
field_name.clone(),
));
}
}
}
}
}
fn detect_altered_fields(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), to_model) in &self.to_state.models {
if let Some(from_model) = self
.from_state
.get_model_by_table_name(app_label, &to_model.table_name)
{
for (field_name, to_field) in &to_model.fields {
if let Some(from_field) = from_model.fields.get(field_name) {
if self.has_field_changed(field_name, from_field, to_field) {
changes.altered_fields.push((
app_label.clone(),
model_name.clone(),
field_name.clone(),
));
}
}
}
}
}
}
fn has_field_changed(
&self,
field_name: &str,
from_field: &FieldState,
to_field: &FieldState,
) -> bool {
if from_field.field_type != to_field.field_type {
return true;
}
if from_field.nullable != to_field.nullable {
return true;
}
let from_def = super::ColumnDefinition::from_field_state(field_name, from_field);
let to_def = super::ColumnDefinition::from_field_state(field_name, to_field);
from_def.primary_key != to_def.primary_key
|| from_def.auto_increment != to_def.auto_increment
|| from_def.unique != to_def.unique
|| from_def.default != to_def.default
}
fn detect_renamed_models(&self, changes: &mut DetectedChanges) {
let deleted: Vec<_> = self
.from_state
.models
.keys()
.filter(|k| !self.to_state.models.contains_key(k))
.collect();
let created: Vec<_> = self
.to_state
.models
.keys()
.filter(|k| !self.from_state.models.contains_key(k))
.collect();
let matches = self.find_optimal_model_matches(&deleted, &created);
for (deleted_key, created_key, _similarity) in matches {
if deleted_key.0 == created_key.0 {
let old_table = self
.from_state
.get_model(&deleted_key.0, &deleted_key.1)
.map(|m| m.table_name.as_str());
let new_table = self
.to_state
.get_model(&created_key.0, &created_key.1)
.map(|m| m.table_name.as_str());
if old_table != new_table {
changes
.renamed_models
.push((deleted_key.0, deleted_key.1, created_key.1));
}
} else {
let old_table = format!("{}_{}", deleted_key.0, deleted_key.1.to_lowercase());
let new_table = format!("{}_{}", created_key.0, created_key.1.to_lowercase());
let rename_table = old_table != new_table || deleted_key.1 != created_key.1;
changes.moved_models.push((
deleted_key.0, created_key.0, created_key.1, rename_table,
if rename_table { Some(old_table) } else { None },
if rename_table { Some(new_table) } else { None },
));
}
}
}
fn detect_renamed_fields(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), from_model) in &self.from_state.models {
if let Some(to_model) = self.to_state.get_model(app_label, model_name) {
let removed_fields: Vec<_> = from_model
.fields
.iter()
.filter(|(name, _)| !to_model.fields.contains_key(*name))
.collect();
let added_fields: Vec<_> = to_model
.fields
.iter()
.filter(|(name, _)| !from_model.fields.contains_key(*name))
.collect();
for (removed_name, removed_field) in &removed_fields {
for (added_name, added_field) in &added_fields {
if removed_field.field_type == added_field.field_type
&& removed_field.nullable == added_field.nullable
{
changes.renamed_fields.push((
app_label.clone(),
model_name.clone(),
removed_name.to_string(),
added_name.to_string(),
));
break;
}
}
}
}
}
}
fn calculate_model_similarity(&self, from_model: &ModelState, to_model: &ModelState) -> f64 {
if from_model.fields.is_empty() && to_model.fields.is_empty() {
return 1.0;
}
if from_model.fields.is_empty() || to_model.fields.is_empty() {
return 0.0;
}
let mut total_similarity = 0.0;
let total_fields = from_model.fields.len().max(to_model.fields.len());
let mut matched_to_fields = std::collections::HashSet::new();
for (from_field_name, from_field) in &from_model.fields {
let mut best_match_score = 0.0;
let mut best_match_name = None;
for (to_field_name, to_field) in &to_model.fields {
if matched_to_fields.contains(to_field_name) {
continue;
}
let similarity = self.calculate_field_similarity(
from_field_name,
to_field_name,
from_field,
to_field,
);
if similarity > best_match_score {
best_match_score = similarity;
best_match_name = Some(to_field_name.clone());
}
}
if let Some(matched_name) = best_match_name {
matched_to_fields.insert(matched_name);
total_similarity += best_match_score;
}
}
total_similarity / total_fields as f64
}
fn calculate_field_similarity(
&self,
from_field_name: &str,
to_field_name: &str,
from_field: &FieldState,
to_field: &FieldState,
) -> f64 {
if from_field.field_type != to_field.field_type {
return 0.0;
}
let jaro_winkler_sim = jaro_winkler(from_field_name, to_field_name);
let lev_distance = levenshtein(from_field_name, to_field_name);
let max_len = from_field_name.len().max(to_field_name.len()) as f64;
let levenshtein_sim = if max_len > 0.0 {
1.0 - (lev_distance as f64 / max_len)
} else {
1.0 };
let name_similarity = self.similarity_config.jaro_winkler_weight * jaro_winkler_sim
+ self.similarity_config.levenshtein_weight * levenshtein_sim;
let nullable_boost = if from_field.nullable == to_field.nullable {
0.1
} else {
0.0
};
(name_similarity + nullable_boost).min(1.0)
}
fn find_optimal_model_matches(
&self,
deleted: &[&(String, String)],
created: &[&(String, String)],
) -> Vec<ModelMatchResult> {
let mut graph = Graph::<(), f64, Undirected>::new_undirected();
let mut deleted_nodes = Vec::new();
let mut created_nodes = Vec::new();
for _ in deleted {
deleted_nodes.push(graph.add_node(()));
}
for _ in created {
created_nodes.push(graph.add_node(()));
}
for (i, deleted_key) in deleted.iter().enumerate() {
if let Some(from_model) = self.from_state.models.get(*deleted_key) {
for (j, created_key) in created.iter().enumerate() {
if let Some(to_model) = self.to_state.models.get(*created_key) {
let similarity = self.calculate_model_similarity(from_model, to_model);
if similarity >= self.similarity_config.model_threshold() {
graph.add_edge(deleted_nodes[i], created_nodes[j], similarity);
}
}
}
}
}
let mut matches = Vec::new();
let mut used_deleted = std::collections::HashSet::new();
let mut used_created = std::collections::HashSet::new();
let mut weighted_edges: Vec<_> = graph
.edge_references()
.map(|e| (e.source(), e.target(), *e.weight()))
.collect();
weighted_edges.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
for (source, target, weight) in weighted_edges {
let source_idx = deleted_nodes.iter().position(|&n| n == source);
let target_idx = created_nodes.iter().position(|&n| n == target);
if let (Some(i), Some(j)) = (source_idx, target_idx)
&& !used_deleted.contains(&i)
&& !used_created.contains(&j)
{
matches.push((deleted[i].clone(), created[j].clone(), weight));
used_deleted.insert(i);
used_created.insert(j);
}
}
matches
}
fn detect_added_indexes(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), to_model) in &self.to_state.models {
if let Some(from_model) = self
.from_state
.get_model_by_table_name(app_label, &to_model.table_name)
{
for to_index in &to_model.indexes {
if !from_model
.indexes
.iter()
.any(|idx| idx.name == to_index.name)
{
changes.added_indexes.push((
app_label.clone(),
model_name.clone(),
to_index.clone(),
));
}
}
}
}
}
fn detect_removed_indexes(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), from_model) in &self.from_state.models {
if let Some(to_model) = self
.to_state
.get_model_by_table_name(app_label, &from_model.table_name)
{
for from_index in &from_model.indexes {
if !to_model
.indexes
.iter()
.any(|idx| idx.name == from_index.name)
{
changes.removed_indexes.push((
app_label.clone(),
model_name.clone(),
from_index.name.clone(),
));
}
}
}
}
}
fn detect_added_constraints(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), to_model) in &self.to_state.models {
if let Some(from_model) = self
.from_state
.get_model_by_table_name(app_label, &to_model.table_name)
{
for to_constraint in &to_model.constraints {
if from_model
.constraints
.iter()
.any(|c| c.name == to_constraint.name)
{
continue;
}
if Self::single_field_unique_already_present(to_constraint, from_model) {
continue;
}
changes.added_constraints.push((
app_label.clone(),
model_name.clone(),
to_constraint.clone(),
));
}
}
}
}
fn detect_removed_constraints(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), from_model) in &self.from_state.models {
if let Some(to_model) = self
.to_state
.get_model_by_table_name(app_label, &from_model.table_name)
{
for from_constraint in &from_model.constraints {
if to_model
.constraints
.iter()
.any(|c| c.name == from_constraint.name)
{
continue;
}
if Self::single_field_unique_already_present(from_constraint, to_model) {
continue;
}
changes.removed_constraints.push((
app_label.clone(),
model_name.clone(),
from_constraint.name.clone(),
));
}
}
}
}
fn single_field_unique_already_present(
candidate: &ConstraintDefinition,
other_side: &ModelState,
) -> bool {
if !is_single_field_unique(candidate) {
return false;
}
let column = &candidate.fields[0];
let covered_by_constraint = other_side
.constraints
.iter()
.any(|c| is_single_field_unique(c) && &c.fields[0] == column);
if covered_by_constraint {
return true;
}
other_side
.fields
.get(column)
.and_then(|f| f.params.get("unique"))
.map(String::as_str)
== Some("true")
}
fn dedup_redundant_unique_add_constraints(
by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
) {
use std::collections::HashSet;
for operations in by_app.values_mut() {
let mut covered: HashSet<(String, String)> = HashSet::new();
let mut keep = Vec::with_capacity(operations.len());
for op in operations.drain(..) {
match &op {
super::Operation::CreateTable {
name,
columns,
constraints,
..
} => {
for col in columns {
if col.unique {
covered.insert((name.clone(), col.name.clone()));
}
}
for c in constraints {
if let super::operations::Constraint::Unique { columns, .. } = c
&& columns.len() == 1
{
covered.insert((name.clone(), columns[0].clone()));
}
}
keep.push(op);
}
super::Operation::AddColumn { table, column, .. } => {
if column.unique {
covered.insert((table.clone(), column.name.clone()));
}
keep.push(op);
}
super::Operation::AddConstraint {
table,
constraint_sql,
} => {
if let Some(col) = parse_single_column_unique(constraint_sql) {
let key = (table.clone(), col.to_string());
if covered.contains(&key) {
continue;
}
covered.insert(key);
}
keep.push(op);
}
_ => keep.push(op),
}
}
*operations = keep;
}
}
fn detect_composite_pk_changes(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), to_model) in &self.to_state.models {
let from_model = self
.from_state
.get_model_by_table_name(app_label, &to_model.table_name);
for constraint in &to_model.constraints {
if constraint.constraint_type != "primary_key" || constraint.fields.len() < 2 {
continue;
}
let from_pk = from_model
.and_then(|m| m.constraints.iter().find(|c| c.name == constraint.name));
match from_pk {
Some(existing) if existing.fields == constraint.fields => {
}
Some(_) => {
changes.removed_composite_primary_keys.push((
app_label.clone(),
model_name.clone(),
constraint.name.clone(),
));
changes.added_composite_primary_keys.push((
app_label.clone(),
model_name.clone(),
constraint.clone(),
));
}
None => {
changes.added_composite_primary_keys.push((
app_label.clone(),
model_name.clone(),
constraint.clone(),
));
}
}
}
}
}
fn detect_auto_increment_resets(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), to_model) in &self.to_state.models {
let Some(value_str) = to_model.options.get("sequence_reset") else {
continue;
};
let from_value = self
.from_state
.get_model(app_label, model_name)
.and_then(|m| m.options.get("sequence_reset"))
.map(String::as_str);
if from_value == Some(value_str.as_str()) {
continue;
}
let Ok(value) = value_str.parse::<i64>() else {
eprintln!(
"Invalid sequence_reset value for {}.{}: {:?}. Expected an integer.",
app_label, model_name, value_str
);
continue;
};
let Some(column) = to_model
.fields
.iter()
.find(|(_, f)| f.params.get("auto_increment").is_some_and(|v| v == "true"))
.map(|(name, _)| name.clone())
else {
continue;
};
changes.auto_increment_resets.push((
app_label.clone(),
model_name.clone(),
column,
value,
));
}
}
fn generate_intermediate_table(
&self,
app_label: &str,
model_name: &str,
field_name: &str,
to_model: &str,
through_table: &Option<String>,
) -> Option<super::Operation> {
let source_table = self
.to_state
.get_model(app_label, model_name)
.map(|m| m.table_name.clone())
.unwrap_or_else(|| {
format!("{}_{}", to_snake_case(app_label), to_snake_case(model_name))
});
let (target_app, target_model) = self.parse_model_reference(to_model, app_label)?;
let target_table = self
.to_state
.get_model(&target_app, &target_model)
.map(|m| m.table_name.clone())
.or_else(|| {
super::model_registry::global_registry()
.get_models()
.iter()
.find(|m| m.app_label == target_app && m.model_name == target_model)
.map(|m| m.table_name.clone())
})
.unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(&target_model)));
let table_name = if let Some(custom_name) = through_table {
custom_name.clone()
} else {
format!(
"{}_{}",
source_table.to_lowercase(),
to_snake_case(field_name)
)
};
let source_table_lower = source_table.to_lowercase();
let target_table_lower = target_table.to_lowercase();
let (source_column, target_column) = if source_table_lower == target_table_lower {
(
format!("from_{}_id", source_table_lower),
format!("to_{}_id", target_table_lower),
)
} else {
(
format!("{}_id", source_table_lower),
format!("{}_id", target_table_lower),
)
};
let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
let target_pk_type = self
.to_state
.get_primary_key_type(&target_app, &target_model);
let columns = vec![
super::ColumnDefinition {
name: "id".to_string(),
type_definition: super::FieldType::BigInteger,
not_null: true,
unique: false,
primary_key: true,
auto_increment: true,
default: None,
},
super::ColumnDefinition {
name: source_column.clone(),
type_definition: source_pk_type,
not_null: true,
unique: false,
primary_key: false,
auto_increment: false,
default: None,
},
super::ColumnDefinition {
name: target_column.clone(),
type_definition: target_pk_type,
not_null: true,
unique: false,
primary_key: false,
auto_increment: false,
default: None,
},
];
let constraints = vec![
super::Constraint::ForeignKey {
name: format!("fk_{}_{}", table_name, source_column),
columns: vec![source_column.clone()],
referenced_table: source_table.clone(),
referenced_columns: vec!["id".to_string()],
on_delete: super::ForeignKeyAction::Cascade,
on_update: super::ForeignKeyAction::Cascade,
deferrable: None,
},
super::Constraint::ForeignKey {
name: format!("fk_{}_{}", table_name, target_column),
columns: vec![target_column.clone()],
referenced_table: target_table.clone(),
referenced_columns: vec!["id".to_string()],
on_delete: super::ForeignKeyAction::Cascade,
on_update: super::ForeignKeyAction::Cascade,
deferrable: None,
},
super::Constraint::Unique {
name: format!(
"uq_{}_{}_{}",
table_name,
source_column.replace("_id", ""),
target_column.replace("_id", "")
),
columns: vec![source_column, target_column],
},
];
Some(super::Operation::CreateTable {
name: table_name,
columns,
constraints,
without_rowid: None,
interleave_in_parent: None,
partition: None,
})
}
fn sort_operations_by_dependency(
&self,
mut operations: Vec<super::Operation>,
) -> Vec<super::Operation> {
let mut sorted = Vec::new();
let create_tables: Vec<_> = operations
.iter()
.filter(|op| matches!(op, super::Operation::CreateTable { .. }))
.cloned()
.collect();
operations.retain(|op| !matches!(op, super::Operation::CreateTable { .. }));
let field_ops: Vec<_> = operations
.iter()
.filter(|op| {
matches!(
op,
super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
)
})
.cloned()
.collect();
operations.retain(|op| {
!matches!(
op,
super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
)
});
sorted.extend(create_tables);
sorted.extend(field_ops);
sorted.extend(operations);
sorted
}
pub fn generate_operations(&self) -> Vec<super::Operation> {
let changes = self.detect_changes();
let mut by_app: std::collections::BTreeMap<String, Vec<super::Operation>> =
std::collections::BTreeMap::new();
self.emit_shared_per_app_operations(&changes, &mut by_app);
for (app_label, model_name) in &changes.created_models {
if let Some(model) = self.to_state.get_model(app_label, model_name) {
for (field_name, field_state) in &model.fields {
if let super::FieldType::ManyToMany { to, through } = &field_state.field_type
&& let Some(operation) = self.generate_intermediate_table(
app_label, model_name, field_name, to, through,
) {
by_app.entry(app_label.clone()).or_default().push(operation);
}
}
}
}
for (app_label, model_name, field_name) in &changes.added_fields {
if let Some(model) = self.to_state.get_model(app_label, model_name)
&& let Some(field) = model.get_field(field_name)
&& let super::FieldType::ManyToMany { to, through } = &field.field_type
&& let Some(operation) =
self.generate_intermediate_table(app_label, model_name, field_name, to, through)
{
by_app.entry(app_label.clone()).or_default().push(operation);
}
}
Self::dedup_redundant_unique_add_constraints(&mut by_app);
let operations: Vec<super::Operation> = by_app.into_values().flatten().collect();
self.sort_operations_by_dependency(operations)
}
fn emit_shared_per_app_operations(
&self,
changes: &DetectedChanges,
by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
) {
for (app_label, model_name) in &changes.created_models {
if let Some(model) = self.to_state.get_model(app_label, model_name) {
let mut columns = Vec::new();
for (field_name, field_state) in &model.fields {
columns.push(super::ColumnDefinition::from_field_state(
field_name.clone(),
field_state,
));
}
let constraints: Vec<super::operations::Constraint> = model
.constraints
.iter()
.map(|c| c.to_constraint())
.collect();
by_app
.entry(app_label.clone())
.or_default()
.push(super::Operation::CreateTable {
name: model.table_name.clone(),
columns,
constraints,
without_rowid: None,
interleave_in_parent: None,
partition: None,
});
}
}
for (app_label, model_name, field_name) in &changes.added_fields {
if let Some(model) = self.to_state.get_model(app_label, model_name)
&& let Some(field) = model.get_field(field_name)
{
by_app
.entry(app_label.clone())
.or_default()
.push(super::Operation::AddColumn {
table: model.table_name.clone(),
column: super::ColumnDefinition::from_field_state(
field_name.clone(),
field,
),
mysql_options: None,
});
}
}
for (app_label, model_name, field_name) in &changes.altered_fields {
if let Some(model) = self.to_state.get_model(app_label, model_name)
&& let Some(field) = model.get_field(field_name)
{
by_app
.entry(app_label.clone())
.or_default()
.push(super::Operation::AlterColumn {
table: model.table_name.clone(),
old_definition: None,
column: field_name.clone(),
new_definition: super::ColumnDefinition::from_field_state(
field_name.clone(),
field,
),
mysql_options: None,
});
}
}
for (app_label, model_name, field_name) in &changes.removed_fields {
if let Some(model) = self.from_state.get_model(app_label, model_name) {
by_app
.entry(app_label.clone())
.or_default()
.push(super::Operation::DropColumn {
table: model.table_name.clone(),
column: field_name.clone(),
});
}
}
for (app_label, model_name) in &changes.deleted_models {
if let Some(model) = self.from_state.get_model(app_label, model_name) {
by_app
.entry(app_label.clone())
.or_default()
.push(super::Operation::DropTable {
name: model.table_name.clone(),
});
}
}
for (app_label, model_name, constraint_name) in &changes.removed_composite_primary_keys {
if let Some(model) = self.from_state.get_model(app_label, model_name) {
by_app.entry(app_label.clone()).or_default().push(
super::Operation::DropConstraint {
table: model.table_name.clone(),
constraint_name: constraint_name.clone(),
},
);
}
}
for (app_label, model_name, constraint) in &changes.added_composite_primary_keys {
if let Some(model) = self.to_state.get_model(app_label, model_name) {
by_app.entry(app_label.clone()).or_default().push(
super::Operation::CreateCompositePrimaryKey {
table: model.table_name.clone(),
columns: constraint.fields.clone(),
constraint_name: Some(constraint.name.clone()),
},
);
}
}
for (app_label, model_name, constraint_name) in &changes.removed_constraints {
let Some(from_model) = self.from_state.get_model(app_label, model_name) else {
continue;
};
let is_composite_pk = from_model
.constraints
.iter()
.find(|c| &c.name == constraint_name)
.is_some_and(|c| c.constraint_type == "primary_key" && c.fields.len() >= 2);
if is_composite_pk {
continue;
}
by_app
.entry(app_label.clone())
.or_default()
.push(super::Operation::DropConstraint {
table: from_model.table_name.clone(),
constraint_name: constraint_name.clone(),
});
}
for (app_label, model_name, constraint) in &changes.added_constraints {
if constraint.constraint_type == "primary_key" && constraint.fields.len() >= 2 {
continue;
}
let Some(to_model) = self.to_state.get_model(app_label, model_name) else {
continue;
};
let constraint_sql = constraint.to_constraint().to_string();
by_app
.entry(app_label.clone())
.or_default()
.push(super::Operation::AddConstraint {
table: to_model.table_name.clone(),
constraint_sql,
});
}
for (app_label, model_name, column, value) in &changes.auto_increment_resets {
if let Some(model) = self.to_state.get_model(app_label, model_name) {
by_app.entry(app_label.clone()).or_default().push(
super::Operation::SetAutoIncrementValue {
table: model.table_name.clone(),
column: column.clone(),
value: *value,
},
);
}
}
}
pub fn generate_migrations(&self) -> Vec<super::Migration> {
let changes = self.detect_changes();
let mut migrations_by_app: std::collections::BTreeMap<String, Vec<super::Operation>> =
std::collections::BTreeMap::new();
self.emit_shared_per_app_operations(&changes, &mut migrations_by_app);
for (app_label, model_name, through_table, m2m) in &changes.created_many_to_many {
let source_table = self
.to_state
.get_model(app_label, model_name)
.map(|m| m.table_name.clone())
.unwrap_or_else(|| format!("{}_{}", app_label, model_name.to_lowercase()));
let (parsed_target_app, parsed_target_model) = self
.parse_model_reference(&m2m.to_model, app_label)
.unwrap_or_else(|| (app_label.to_string(), m2m.to_model.clone()));
let target_table = self
.to_state
.get_model(&parsed_target_app, &parsed_target_model)
.map(|model| model.table_name.clone())
.or_else(|| {
super::model_registry::global_registry()
.get_models()
.iter()
.find(|m| {
m.app_label == parsed_target_app && m.model_name == parsed_target_model
})
.map(|m| m.table_name.clone())
})
.unwrap_or_else(|| {
format!(
"{}_{}",
parsed_target_app,
parsed_target_model.to_lowercase()
)
});
let (default_source_col, default_target_col) =
crate::m2m_naming::default_m2m_columns(&source_table, &target_table);
let source_column = m2m.source_field.clone().unwrap_or(default_source_col);
let target_column = m2m.target_field.clone().unwrap_or(default_target_col);
let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
let target_pk_type = self
.to_state
.get_primary_key_type(&parsed_target_app, &parsed_target_model);
let columns = vec![
super::ColumnDefinition {
name: "id".to_string(),
type_definition: super::FieldType::Integer,
not_null: true,
unique: false,
primary_key: true,
auto_increment: true,
default: None,
},
super::ColumnDefinition {
name: source_column.clone(),
type_definition: source_pk_type.clone(),
not_null: true,
unique: false,
primary_key: false,
auto_increment: false,
default: None,
},
super::ColumnDefinition {
name: target_column.clone(),
type_definition: target_pk_type,
not_null: true,
unique: false,
primary_key: false,
auto_increment: false,
default: None,
},
];
let constraints = vec![
super::operations::Constraint::ForeignKey {
name: format!("fk_{}_{}", through_table, source_column),
columns: vec![source_column.clone()],
referenced_table: source_table.clone(),
referenced_columns: vec!["id".to_string()],
on_delete: ForeignKeyAction::Cascade,
on_update: ForeignKeyAction::Cascade,
deferrable: None,
},
super::operations::Constraint::ForeignKey {
name: format!("fk_{}_{}", through_table, target_column),
columns: vec![target_column.clone()],
referenced_table: target_table,
referenced_columns: vec!["id".to_string()],
on_delete: ForeignKeyAction::Cascade,
on_update: ForeignKeyAction::Cascade,
deferrable: None,
},
super::operations::Constraint::Unique {
name: format!("{}_unique", through_table),
columns: vec![source_column, target_column],
},
];
migrations_by_app
.entry(app_label.clone())
.or_default()
.push(super::Operation::CreateTable {
name: through_table.clone(),
columns,
constraints,
without_rowid: None,
interleave_in_parent: None,
partition: None,
});
}
for (app_label, old_name, new_name) in &changes.renamed_models {
if let Some(model) = self.to_state.get_model(app_label, new_name) {
let old_table_name = self
.from_state
.get_model(app_label, old_name)
.map(|m| m.table_name.clone())
.unwrap_or_else(|| format!("{}_{}", app_label, old_name.to_lowercase()));
if old_table_name != model.table_name {
migrations_by_app
.entry(app_label.clone())
.or_default()
.push(super::Operation::RenameTable {
old_name: old_table_name,
new_name: model.table_name.clone(),
});
}
}
}
for (from_app, to_app, model_name, rename_table, old_table, new_table) in
&changes.moved_models
{
let old_table_name = old_table.clone().unwrap_or_else(|| {
self.from_state
.get_model(from_app, model_name)
.map(|m| m.table_name.clone())
.unwrap_or_else(|| format!("{}_{}", from_app, model_name.to_lowercase()))
});
let new_table_name = new_table.clone().unwrap_or_else(|| {
self.to_state
.get_model(to_app, model_name)
.map(|m| m.table_name.clone())
.unwrap_or_else(|| format!("{}_{}", to_app, model_name.to_lowercase()))
});
migrations_by_app.entry(to_app.clone()).or_default().push(
super::Operation::MoveModel {
model_name: model_name.clone(),
from_app: from_app.clone(),
to_app: to_app.clone(),
rename_table: *rename_table,
old_table_name: if *rename_table {
Some(old_table_name)
} else {
None
},
new_table_name: if *rename_table {
Some(new_table_name)
} else {
None
},
},
);
}
Self::dedup_redundant_unique_add_constraints(&mut migrations_by_app);
let mut migrations = Vec::new();
for (app_label, operations) in migrations_by_app {
let migration_name = "autodetected".to_string();
let mut migration = super::Migration::new(&migration_name, &app_label);
for operation in operations {
migration = migration.add_operation(operation);
}
migrations.push(migration);
}
migrations
}
fn detect_created_many_to_many(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), model_state) in &self.to_state.models {
for m2m in &model_state.many_to_many_fields {
let through_table = m2m.through.clone().unwrap_or_else(|| {
crate::m2m_naming::default_through_table(
&model_state.table_name,
&m2m.field_name,
)
});
let exists_in_from = self
.from_state
.find_model_by_table(&through_table)
.is_some();
if !exists_in_from {
changes.created_many_to_many.push((
app_label.clone(),
model_name.clone(),
through_table.clone(),
m2m.clone(),
));
let target_app = self
.find_model_app(&m2m.to_model)
.unwrap_or_else(|| app_label.clone());
changes
.model_dependencies
.entry((app_label.clone(), through_table))
.or_default()
.extend(vec![
(app_label.clone(), model_name.clone()),
(target_app, m2m.to_model.clone()),
]);
}
}
}
}
fn find_model_app(&self, model_name: &str) -> Option<String> {
for (app_label, name) in self.to_state.models.keys() {
if name == model_name {
return Some(app_label.clone());
}
}
for model_meta in super::model_registry::global_registry().get_models() {
if model_meta.model_name == model_name {
return Some(model_meta.app_label.clone());
}
}
None
}
fn detect_model_dependencies(&self, changes: &mut DetectedChanges) {
for ((app_label, model_name), model) in &self.to_state.models {
let mut dependencies = Vec::new();
for field in model.fields.values() {
match &field.field_type {
super::FieldType::ForeignKey { to_table, .. } => {
if let Some(dep) = self.find_model_by_table_name(to_table) {
if dep != (app_label.clone(), model_name.clone()) {
dependencies.push(dep);
}
}
}
super::FieldType::OneToOne { to, .. } => {
if let Some(dep) = self.parse_model_reference(to, app_label)
&& dep != (app_label.clone(), model_name.clone())
{
dependencies.push(dep);
}
}
super::FieldType::ManyToMany { to, .. } => {
if let Some(dep) = self.parse_model_reference(to, app_label)
&& dep != (app_label.clone(), model_name.clone())
{
dependencies.push(dep);
}
}
super::FieldType::Custom(s) => {
if let Some(referenced_model) = self.extract_related_model(s, app_label)
&& referenced_model != (app_label.clone(), model_name.clone())
{
dependencies.push(referenced_model);
}
}
_ => {}
}
}
if !dependencies.is_empty() {
changes
.model_dependencies
.insert((app_label.clone(), model_name.clone()), dependencies);
}
}
}
fn extract_related_model(
&self,
field_type: &str,
current_app: &str,
) -> Option<(String, String)> {
if let Some(inner) = field_type
.strip_prefix("ForeignKey(")
.and_then(|s| s.strip_suffix(")"))
{
return self.parse_model_reference(inner, current_app);
}
if let Some(inner) = field_type
.strip_prefix("ManyToManyField(")
.and_then(|s| s.strip_suffix(")"))
{
return self.parse_model_reference(inner, current_app);
}
if let Some(inner) = field_type
.strip_prefix("OneToOneField(")
.and_then(|s| s.strip_suffix(")"))
{
return self.parse_model_reference(inner, current_app);
}
None
}
fn parse_model_reference(
&self,
reference: &str,
current_app: &str,
) -> Option<(String, String)> {
let parts: Vec<&str> = reference.split('.').collect();
match parts.as_slice() {
[app, model] => Some((app.to_string(), model.to_string())),
[model] => {
Some((current_app.to_string(), model.to_string()))
}
_ => None,
}
}
fn find_model_by_table_name(&self, table_name: &str) -> Option<(String, String)> {
for (app_label, model_name) in self.to_state.models.keys() {
let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
if django_table == table_name {
return Some((app_label.clone(), model_name.clone()));
}
if model_name.to_lowercase() == table_name {
return Some((app_label.clone(), model_name.clone()));
}
}
for (app_label, model_name) in self.from_state.models.keys() {
let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
if django_table == table_name {
return Some((app_label.clone(), model_name.clone()));
}
if model_name.to_lowercase() == table_name {
return Some((app_label.clone(), model_name.clone()));
}
}
None
}
}
impl ModelState {
pub fn remove_field(&mut self, name: &str) {
self.fields.remove(name);
}
pub fn alter_field(&mut self, name: &str, new_field: FieldState) {
self.fields.insert(name.to_string(), new_field);
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
fn build_project_state(models: Vec<((String, String), ModelState)>) -> ProjectState {
let mut state = ProjectState::new();
for (key, model) in models {
state.models.insert(key, model);
}
state
}
fn build_model_state(
app_label: &str,
name: &str,
fields: Vec<FieldState>,
indexes: Vec<IndexDefinition>,
constraints: Vec<ConstraintDefinition>,
) -> ModelState {
let mut field_map = std::collections::BTreeMap::new();
for f in fields {
field_map.insert(f.name.clone(), f);
}
ModelState {
app_label: app_label.to_string(),
name: name.to_string(),
table_name: format!("{}_{}", app_label, name.to_lowercase()),
fields: field_map,
options: std::collections::HashMap::new(),
base_model: None,
inheritance_type: None,
discriminator_column: None,
indexes,
constraints,
many_to_many_fields: Vec::new(),
}
}
#[rstest]
fn to_database_schema_uses_app_prefixed_table_key() {
let model = build_model_state(
"blog",
"Post",
vec![FieldState::new(
"id",
super::super::FieldType::Integer,
false,
)],
Vec::new(),
Vec::new(),
);
let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
let schema = state.to_database_schema();
assert_eq!(schema.tables.len(), 1);
assert!(
schema.tables.contains_key("blog_post"),
"table key should be app_label + '_' + lowercase model name"
);
let table = &schema.tables["blog_post"];
assert_eq!(table.name, "blog_post");
}
#[rstest]
fn to_database_schema_prevents_cross_app_collision() {
let blog_user = build_model_state(
"blog",
"User",
vec![FieldState::new(
"id",
super::super::FieldType::Integer,
false,
)],
Vec::new(),
Vec::new(),
);
let auth_user = build_model_state(
"auth",
"User",
vec![FieldState::new(
"id",
super::super::FieldType::Integer,
false,
)],
Vec::new(),
Vec::new(),
);
let state = build_project_state(vec![
(("blog".to_string(), "User".to_string()), blog_user),
(("auth".to_string(), "User".to_string()), auth_user),
]);
let schema = state.to_database_schema();
assert_eq!(schema.tables.len(), 2);
assert!(schema.tables.contains_key("blog_user"));
assert!(schema.tables.contains_key("auth_user"));
}
#[rstest]
fn to_database_schema_propagates_indexes() {
let indexes = vec![
IndexDefinition {
name: "idx_title".to_string(),
fields: vec!["title".to_string()],
unique: false,
},
IndexDefinition {
name: "idx_slug_unique".to_string(),
fields: vec!["slug".to_string()],
unique: true,
},
];
let model = build_model_state(
"blog",
"Post",
vec![
FieldState::new("title", super::super::FieldType::VarChar(255), false),
FieldState::new("slug", super::super::FieldType::VarChar(100), false),
],
indexes,
Vec::new(),
);
let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
let schema = state.to_database_schema();
let table = &schema.tables["blog_post"];
assert_eq!(table.indexes.len(), 2);
assert_eq!(table.indexes[0].name, "idx_title");
assert_eq!(table.indexes[0].columns, vec!["title".to_string()]);
assert!(!table.indexes[0].unique);
assert_eq!(table.indexes[1].name, "idx_slug_unique");
assert!(table.indexes[1].unique);
}
#[rstest]
fn to_database_schema_propagates_constraints() {
let constraints = vec![ConstraintDefinition {
name: "uq_email".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["email".to_string()],
expression: None,
foreign_key_info: None,
}];
let model = build_model_state(
"auth",
"Account",
vec![FieldState::new(
"email",
super::super::FieldType::VarChar(255),
false,
)],
Vec::new(),
constraints,
);
let state = build_project_state(vec![(("auth".to_string(), "Account".to_string()), model)]);
let schema = state.to_database_schema();
let table = &schema.tables["auth_account"];
assert_eq!(table.constraints.len(), 1);
assert_eq!(table.constraints[0].name, "uq_email");
assert_eq!(table.constraints[0].constraint_type, "unique");
assert_eq!(table.constraints[0].definition, "email");
}
#[rstest]
fn to_database_schema_maps_field_params() {
let mut field = FieldState::new("id", super::super::FieldType::Integer, false);
field
.params
.insert("primary_key".to_string(), "true".to_string());
field
.params
.insert("auto_increment".to_string(), "true".to_string());
field.params.insert("default".to_string(), "0".to_string());
let mut nullable_field = FieldState::new("bio", super::super::FieldType::Text, true);
nullable_field
.params
.insert("default".to_string(), "''".to_string());
let model = build_model_state(
"users",
"Profile",
vec![field, nullable_field],
Vec::new(),
Vec::new(),
);
let state =
build_project_state(vec![(("users".to_string(), "Profile".to_string()), model)]);
let schema = state.to_database_schema();
let table = &schema.tables["users_profile"];
let id_col = &table.columns["id"];
assert!(id_col.primary_key);
assert!(id_col.auto_increment);
assert_eq!(id_col.default, Some("0".to_string()));
assert!(!id_col.nullable);
let bio_col = &table.columns["bio"];
assert!(!bio_col.primary_key);
assert!(!bio_col.auto_increment);
assert!(bio_col.nullable);
assert_eq!(bio_col.default, Some("''".to_string()));
}
#[rstest]
fn to_database_schema_for_app_filters_by_app_label() {
let blog_post = build_model_state(
"blog",
"Post",
vec![FieldState::new(
"id",
super::super::FieldType::Integer,
false,
)],
Vec::new(),
Vec::new(),
);
let auth_user = build_model_state(
"auth",
"User",
vec![FieldState::new(
"id",
super::super::FieldType::Integer,
false,
)],
Vec::new(),
Vec::new(),
);
let state = build_project_state(vec![
(("blog".to_string(), "Post".to_string()), blog_post),
(("auth".to_string(), "User".to_string()), auth_user),
]);
let blog_schema = state.to_database_schema_for_app("blog");
let auth_schema = state.to_database_schema_for_app("auth");
let empty_schema = state.to_database_schema_for_app("nonexistent");
assert_eq!(blog_schema.tables.len(), 1);
assert!(blog_schema.tables.contains_key("blog_post"));
assert_eq!(auth_schema.tables.len(), 1);
assert!(auth_schema.tables.contains_key("auth_user"));
assert_eq!(empty_schema.tables.len(), 0);
}
#[rstest]
fn to_database_schema_for_app_propagates_indexes_and_constraints() {
let indexes = vec![IndexDefinition {
name: "idx_created".to_string(),
fields: vec!["created_at".to_string()],
unique: false,
}];
let constraints = vec![ConstraintDefinition {
name: "ck_status".to_string(),
constraint_type: "check".to_string(),
fields: vec!["status".to_string()],
expression: Some("status IN ('draft', 'published')".to_string()),
foreign_key_info: None,
}];
let model = build_model_state(
"blog",
"Post",
vec![
FieldState::new("created_at", super::super::FieldType::DateTime, false),
FieldState::new("status", super::super::FieldType::VarChar(20), false),
],
indexes,
constraints,
);
let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
let schema = state.to_database_schema_for_app("blog");
let table = &schema.tables["blog_post"];
assert_eq!(table.indexes.len(), 1);
assert_eq!(table.indexes[0].name, "idx_created");
assert_eq!(table.indexes[0].columns, vec!["created_at".to_string()]);
assert_eq!(table.constraints.len(), 1);
assert_eq!(table.constraints[0].name, "ck_status");
assert_eq!(table.constraints[0].constraint_type, "check");
assert_eq!(table.constraints[0].definition, "status");
}
fn build_model_state_with_table_name(
app_label: &str,
name: &str,
table_name: &str,
fields: Vec<FieldState>,
) -> ModelState {
let mut field_map = std::collections::BTreeMap::new();
for f in fields {
field_map.insert(f.name.clone(), f);
}
ModelState {
app_label: app_label.to_string(),
name: name.to_string(),
table_name: table_name.to_string(),
fields: field_map,
options: std::collections::HashMap::new(),
base_model: None,
inheritance_type: None,
discriminator_column: None,
indexes: Vec::new(),
constraints: Vec::new(),
many_to_many_fields: Vec::new(),
}
}
#[rstest]
fn to_database_schema_respects_custom_table_name() {
let model = build_model_state_with_table_name(
"blog",
"Post",
"custom_posts_table",
vec![FieldState::new(
"id",
super::super::FieldType::Integer,
false,
)],
);
let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
let schema = state.to_database_schema();
assert!(schema.tables.contains_key("blog_post"));
let table = &schema.tables["blog_post"];
assert_eq!(table.name, "custom_posts_table");
}
#[rstest]
fn to_database_schema_for_app_respects_custom_table_name() {
let model = build_model_state_with_table_name(
"blog",
"Post",
"custom_posts_table",
vec![FieldState::new(
"id",
super::super::FieldType::Integer,
false,
)],
);
let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
let schema = state.to_database_schema_for_app("blog");
assert!(schema.tables.contains_key("blog_post"));
let table = &schema.tables["blog_post"];
assert_eq!(table.name, "custom_posts_table");
}
fn sample_fields() -> Vec<FieldState> {
vec![
FieldState::new("id", super::super::FieldType::Integer, false),
FieldState::new("name", super::super::FieldType::VarChar(255), false),
]
}
#[rstest]
fn detect_created_many_to_many_recognises_existing_through_table_by_table_name() {
use super::super::model_registry::ManyToManyMetadata;
let from_room = build_model_state_with_table_name("dm", "Room", "dm_room", sample_fields());
let from_through = build_model_state_with_table_name(
"dm",
"RoomMembers",
"dm_room_members",
sample_fields(),
);
let from_state = build_project_state(vec![
(("dm".to_string(), "Room".to_string()), from_room),
(("dm".to_string(), "RoomMembers".to_string()), from_through),
]);
let mut to_room =
build_model_state_with_table_name("dm", "DMRoom", "dm_room", sample_fields());
to_room
.many_to_many_fields
.push(ManyToManyMetadata::new("members", "User"));
let to_through = build_model_state_with_table_name(
"dm",
"DMRoomMembers",
"dm_room_members",
sample_fields(),
);
let to_state = build_project_state(vec![
(("dm".to_string(), "DMRoom".to_string()), to_room),
(("dm".to_string(), "DMRoomMembers".to_string()), to_through),
]);
let detector = MigrationAutodetector::new(from_state, to_state);
let changes = detector.detect_changes();
assert!(
changes.created_many_to_many.is_empty(),
"M2M through table already exists in from_state; expected no \
created_many_to_many, got {:?}",
changes.created_many_to_many
);
}
#[rstest]
fn detect_renamed_models_skips_struct_only_rename_with_same_table_name() {
let from_model =
build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
let to_model =
build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
let from_state = build_project_state(vec![(
("myapp".to_string(), "Clusters".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("myapp".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let changes = detector.detect_changes();
assert!(
changes.renamed_models.is_empty(),
"struct-only rename with same table name should not produce renamed_models"
);
}
#[rstest]
fn detect_renamed_models_detects_actual_table_rename() {
let from_model =
build_model_state_with_table_name("myapp", "OldModel", "old_table", sample_fields());
let to_model =
build_model_state_with_table_name("myapp", "NewModel", "new_table", sample_fields());
let from_state = build_project_state(vec![(
("myapp".to_string(), "OldModel".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("myapp".to_string(), "NewModel".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let changes = detector.detect_changes();
assert_eq!(
changes.renamed_models.len(),
1,
"actual table rename should be detected"
);
assert_eq!(changes.renamed_models[0].1, "OldModel");
assert_eq!(changes.renamed_models[0].2, "NewModel");
}
#[rstest]
fn has_field_changed_ignores_non_schema_params() {
let from_field = FieldState {
name: "email".to_string(),
field_type: super::super::FieldType::VarChar(255),
nullable: false,
params: std::collections::HashMap::new(),
foreign_key: None,
};
let mut to_params = std::collections::HashMap::new();
to_params.insert("max_length".to_string(), "255".to_string());
to_params.insert("null".to_string(), "false".to_string());
to_params.insert("blank".to_string(), "false".to_string());
let to_field = FieldState {
name: "email".to_string(),
field_type: super::super::FieldType::VarChar(255),
nullable: false,
params: to_params,
foreign_key: None,
};
let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
let changed = detector.has_field_changed("email", &from_field, &to_field);
assert!(
!changed,
"fields with identical schema but different non-schema params should not be detected as changed"
);
}
#[rstest]
fn generate_operations_empty_for_struct_only_rename() {
let from_model =
build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
let to_model =
build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
let from_state = build_project_state(vec![(
("myapp".to_string(), "Clusters".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("myapp".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert!(
operations.is_empty(),
"struct-only rename with same table name and identical fields should produce no operations, got: {:?}",
operations
);
}
#[rstest]
fn detect_composite_pk_added_emits_create_composite_primary_key() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let tenant_id_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
let from_model = build_model_state(
"billing",
"Invoice",
vec![id_field.clone(), tenant_id_field.clone()],
Vec::new(),
Vec::new(),
);
let composite_pk = ConstraintDefinition {
name: "billing_invoice_pkey".to_string(),
constraint_type: "primary_key".to_string(),
fields: vec!["id".to_string(), "tenant_id".to_string()],
expression: None,
foreign_key_info: None,
};
let to_model = build_model_state(
"billing",
"Invoice",
vec![id_field, tenant_id_field],
Vec::new(),
vec![composite_pk],
);
let from_state = build_project_state(vec![(
("billing".to_string(), "Invoice".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("billing".to_string(), "Invoice".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert_eq!(operations.len(), 1);
assert!(
matches!(
&operations[0],
super::super::Operation::CreateCompositePrimaryKey {
table,
columns,
..
} if table == "billing_invoice"
&& columns == &["id".to_string(), "tenant_id".to_string()]
),
"expected CreateCompositePrimaryKey, got: {:?}",
operations
);
}
#[rstest]
fn detect_composite_pk_unchanged_emits_no_operations() {
let composite_pk = ConstraintDefinition {
name: "billing_invoice_pkey".to_string(),
constraint_type: "primary_key".to_string(),
fields: vec!["id".to_string(), "tenant_id".to_string()],
expression: None,
foreign_key_info: None,
};
let from_model = build_model_state(
"billing",
"Invoice",
vec![
FieldState::new("id", super::super::FieldType::Integer, false),
FieldState::new("tenant_id", super::super::FieldType::Integer, false),
],
Vec::new(),
vec![composite_pk.clone()],
);
let to_model = build_model_state(
"billing",
"Invoice",
vec![
FieldState::new("id", super::super::FieldType::Integer, false),
FieldState::new("tenant_id", super::super::FieldType::Integer, false),
],
Vec::new(),
vec![composite_pk],
);
let from_state = build_project_state(vec![(
("billing".to_string(), "Invoice".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("billing".to_string(), "Invoice".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert!(
operations.is_empty(),
"unchanged composite PK should produce no operations, got: {:?}",
operations
);
}
#[rstest]
fn detect_composite_pk_changed_fields_emits_drop_and_create() {
let composite_pk_from = ConstraintDefinition {
name: "billing_invoice_pkey".to_string(),
constraint_type: "primary_key".to_string(),
fields: vec!["id".to_string(), "tenant_id".to_string()],
expression: None,
foreign_key_info: None,
};
let composite_pk_to = ConstraintDefinition {
name: "billing_invoice_pkey".to_string(),
constraint_type: "primary_key".to_string(),
fields: vec!["id".to_string(), "org_id".to_string()],
expression: None,
foreign_key_info: None,
};
let from_model = build_model_state(
"billing",
"Invoice",
vec![
FieldState::new("id", super::super::FieldType::Integer, false),
FieldState::new("tenant_id", super::super::FieldType::Integer, false),
],
Vec::new(),
vec![composite_pk_from],
);
let to_model = build_model_state(
"billing",
"Invoice",
vec![
FieldState::new("id", super::super::FieldType::Integer, false),
FieldState::new("org_id", super::super::FieldType::Integer, false),
],
Vec::new(),
vec![composite_pk_to],
);
let from_state = build_project_state(vec![(
("billing".to_string(), "Invoice".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("billing".to_string(), "Invoice".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
let drop_op = operations.iter().find(|op| {
matches!(op, super::super::Operation::DropConstraint { constraint_name, .. }
if constraint_name == "billing_invoice_pkey")
});
let create_op = operations.iter().find(|op| {
matches!(op, super::super::Operation::CreateCompositePrimaryKey { columns, .. }
if columns == &["id".to_string(), "org_id".to_string()])
});
assert!(
drop_op.is_some(),
"expected DropConstraint for modified composite PK, got: {:?}",
operations
);
assert!(
create_op.is_some(),
"expected CreateCompositePrimaryKey with new fields, got: {:?}",
operations
);
}
#[rstest]
fn detect_sequence_reset_emits_set_auto_increment_value() {
let mut id_field = FieldState::new("id", super::super::FieldType::BigInteger, false);
id_field
.params
.insert("auto_increment".to_string(), "true".to_string());
let from_model = build_model_state(
"shop",
"Order",
vec![id_field.clone()],
Vec::new(),
Vec::new(),
);
let mut to_model =
build_model_state("shop", "Order", vec![id_field], Vec::new(), Vec::new());
to_model
.options
.insert("sequence_reset".to_string(), "1000".to_string());
let from_state = build_project_state(vec![(
("shop".to_string(), "Order".to_string()),
from_model,
)]);
let to_state =
build_project_state(vec![(("shop".to_string(), "Order".to_string()), to_model)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert_eq!(operations.len(), 1);
assert!(
matches!(
&operations[0],
super::super::Operation::SetAutoIncrementValue {
table,
column,
value,
} if table == "shop_order" && column == "id" && *value == 1000
),
"expected SetAutoIncrementValue, got: {:?}",
operations
);
}
#[rstest]
fn detect_added_unique_together_emits_add_constraint() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
let from_model = build_model_state(
"clusters",
"Cluster",
vec![id_field.clone(), org_field.clone(), name_field.clone()],
Vec::new(),
Vec::new(),
);
let unique_constraint = ConstraintDefinition {
name: "clusters_cluster_organization_id_name_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["organization_id".to_string(), "name".to_string()],
expression: None,
foreign_key_info: None,
};
let to_model = build_model_state(
"clusters",
"Cluster",
vec![id_field, org_field, name_field],
Vec::new(),
vec![unique_constraint],
);
let from_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert_eq!(
operations.len(),
1,
"expected exactly one AddConstraint operation, got: {:?}",
operations
);
let super::super::Operation::AddConstraint {
table,
constraint_sql,
} = &operations[0]
else {
panic!(
"expected Operation::AddConstraint, got: {:?}",
operations[0]
);
};
assert_eq!(table, "clusters_cluster");
assert!(
constraint_sql.contains("UNIQUE"),
"constraint SQL should declare UNIQUE, got: {}",
constraint_sql
);
assert!(
constraint_sql.contains("organization_id"),
"constraint SQL should reference organization_id, got: {}",
constraint_sql
);
assert!(
constraint_sql.contains("name"),
"constraint SQL should reference name, got: {}",
constraint_sql
);
assert!(
constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
"constraint SQL should carry the constraint name, got: {}",
constraint_sql
);
}
#[rstest]
fn detect_removed_unique_together_emits_drop_constraint() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
let unique_constraint = ConstraintDefinition {
name: "clusters_cluster_organization_id_name_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["organization_id".to_string(), "name".to_string()],
expression: None,
foreign_key_info: None,
};
let from_model = build_model_state(
"clusters",
"Cluster",
vec![id_field.clone(), org_field.clone(), name_field.clone()],
Vec::new(),
vec![unique_constraint],
);
let to_model = build_model_state(
"clusters",
"Cluster",
vec![id_field, org_field, name_field],
Vec::new(),
Vec::new(),
);
let from_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert_eq!(
operations.len(),
1,
"expected exactly one DropConstraint operation, got: {:?}",
operations
);
let super::super::Operation::DropConstraint {
table,
constraint_name,
} = &operations[0]
else {
panic!(
"expected Operation::DropConstraint, got: {:?}",
operations[0]
);
};
assert_eq!(table, "clusters_cluster");
assert_eq!(
constraint_name,
"clusters_cluster_organization_id_name_uniq"
);
}
#[rstest]
fn detect_added_unique_together_via_offline_reconstructed_from_state() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
let mut from_model = build_model_state(
"clusters",
"Clusters",
vec![id_field.clone(), org_field.clone(), name_field.clone()],
Vec::new(),
Vec::new(),
);
from_model.table_name = "clusters_cluster".to_string();
let unique_constraint = ConstraintDefinition {
name: "clusters_cluster_organization_id_name_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["organization_id".to_string(), "name".to_string()],
expression: None,
foreign_key_info: None,
};
let to_model = build_model_state(
"clusters",
"Cluster",
vec![id_field, org_field, name_field],
Vec::new(),
vec![unique_constraint],
);
let from_state = build_project_state(vec![(
("clusters".to_string(), "Clusters".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert_eq!(
operations.len(),
1,
"expected exactly one AddConstraint operation, got: {:?}",
operations
);
let super::super::Operation::AddConstraint {
table,
constraint_sql,
} = &operations[0]
else {
panic!(
"expected Operation::AddConstraint, got: {:?}",
operations[0]
);
};
assert_eq!(table, "clusters_cluster");
assert!(
constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
"constraint SQL should carry the constraint name, got: {}",
constraint_sql
);
}
#[rstest]
fn detect_removed_unique_together_via_offline_reconstructed_from_state() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
let unique_constraint = ConstraintDefinition {
name: "clusters_cluster_organization_id_name_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["organization_id".to_string(), "name".to_string()],
expression: None,
foreign_key_info: None,
};
let mut from_model = build_model_state(
"clusters",
"Clusters",
vec![id_field.clone(), org_field.clone(), name_field.clone()],
Vec::new(),
vec![unique_constraint],
);
from_model.table_name = "clusters_cluster".to_string();
let to_model = build_model_state(
"clusters",
"Cluster",
vec![id_field, org_field, name_field],
Vec::new(),
Vec::new(),
);
let from_state = build_project_state(vec![(
("clusters".to_string(), "Clusters".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert_eq!(
operations.len(),
1,
"expected exactly one DropConstraint operation, got: {:?}",
operations
);
let super::super::Operation::DropConstraint {
table,
constraint_name,
} = &operations[0]
else {
panic!(
"expected Operation::DropConstraint, got: {:?}",
operations[0]
);
};
assert_eq!(table, "clusters_cluster");
assert_eq!(
constraint_name,
"clusters_cluster_organization_id_name_uniq"
);
}
#[rstest]
fn has_field_changed_ignores_param_population_skew() {
let mut from_params = std::collections::HashMap::new();
from_params.insert("primary_key".to_string(), "true".to_string());
from_params.insert("auto_increment".to_string(), "true".to_string());
let from_field = FieldState {
name: "id".to_string(),
field_type: super::super::FieldType::BigInteger,
nullable: false,
params: from_params,
foreign_key: None,
};
let mut to_params = std::collections::HashMap::new();
to_params.insert("primary_key".to_string(), "true".to_string());
to_params.insert("auto_increment".to_string(), "true".to_string());
to_params.insert("not_null".to_string(), "true".to_string());
to_params.insert("null".to_string(), "false".to_string());
to_params.insert("unique".to_string(), "false".to_string());
let to_field = FieldState {
name: "id".to_string(),
field_type: super::super::FieldType::BigInteger,
nullable: false,
params: to_params,
foreign_key: None,
};
let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
let changed = detector.has_field_changed("id", &from_field, &to_field);
assert!(
!changed,
"identical schema with asymmetric param populations between migration replay and macro registry must not be detected as changed"
);
}
#[rstest]
fn generate_operations_no_spurious_altercolumn_for_pk_via_offline_reconstructed_state() {
let mut from_id_params = std::collections::HashMap::new();
from_id_params.insert("primary_key".to_string(), "true".to_string());
from_id_params.insert("auto_increment".to_string(), "true".to_string());
let from_id_field = FieldState {
name: "id".to_string(),
field_type: super::super::FieldType::BigInteger,
nullable: false,
params: from_id_params,
foreign_key: None,
};
let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
let mut from_model = build_model_state(
"clusters",
"Clusters",
vec![from_id_field, org_field.clone(), name_field.clone()],
Vec::new(),
Vec::new(),
);
from_model.table_name = "clusters_cluster".to_string();
let mut to_id_params = std::collections::HashMap::new();
to_id_params.insert("primary_key".to_string(), "true".to_string());
to_id_params.insert("auto_increment".to_string(), "true".to_string());
to_id_params.insert("not_null".to_string(), "true".to_string());
to_id_params.insert("null".to_string(), "false".to_string());
to_id_params.insert("unique".to_string(), "false".to_string());
let to_id_field = FieldState {
name: "id".to_string(),
field_type: super::super::FieldType::BigInteger,
nullable: false,
params: to_id_params,
foreign_key: None,
};
let unique_constraint = ConstraintDefinition {
name: "clusters_cluster_organization_id_name_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["organization_id".to_string(), "name".to_string()],
expression: None,
foreign_key_info: None,
};
let to_model = build_model_state(
"clusters",
"Cluster",
vec![to_id_field, org_field, name_field],
Vec::new(),
vec![unique_constraint],
);
let from_state = build_project_state(vec![(
("clusters".to_string(), "Clusters".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert!(
!operations
.iter()
.any(|op| matches!(op, super::super::Operation::AlterColumn { .. })),
"no AlterColumn must be emitted for unchanged PK under offline state reconstruction, got: {:?}",
operations
);
assert_eq!(
operations.len(),
1,
"expected exactly one AddConstraint operation, got: {:?}",
operations
);
assert!(
matches!(
&operations[0],
super::super::Operation::AddConstraint { .. }
),
"expected the single operation to be AddConstraint, got: {:?}",
operations[0]
);
}
#[rstest]
fn generate_operations_no_spurious_altercolumn_for_option_pk_via_apply_migration_operations() {
let mut id_meta =
super::super::model_registry::FieldMetadata::new(super::super::FieldType::BigInteger);
id_meta = id_meta
.with_param("primary_key", "true")
.with_param("auto_increment", "true")
.with_param("not_null", "true")
.with_param("null", "false");
let mut name_meta =
super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(255));
name_meta = name_meta
.with_param("max_length", "255")
.with_param("not_null", "true")
.with_param("null", "false");
let mut metadata =
super::super::model_registry::ModelMetadata::new("clusters", "Cluster", "clusters");
metadata.add_field("id".to_string(), id_meta);
metadata.add_field("name".to_string(), name_meta);
let to_model = metadata.to_model_state();
let to_id = to_model.fields.get("id").expect("id field present");
assert!(
!to_id.nullable,
"to_state PK FieldState.nullable must be false; got nullable=true \
with params={:?}. Did the #[model] macro regress to emitting \
null=\"true\" for Option<T> PKs?",
to_id.params
);
let to_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
to_model,
)]);
let create_clusters = super::super::Operation::CreateTable {
name: "clusters".to_string(),
columns: vec![
super::super::ColumnDefinition {
name: "id".to_string(),
type_definition: super::super::FieldType::BigInteger,
not_null: true,
unique: false,
primary_key: true,
auto_increment: true,
default: None,
},
super::super::ColumnDefinition {
name: "name".to_string(),
type_definition: super::super::FieldType::VarChar(255),
not_null: true,
unique: false,
primary_key: false,
auto_increment: false,
default: None,
},
],
constraints: vec![],
without_rowid: None,
interleave_in_parent: None,
partition: None,
};
let mut from_state = ProjectState::new();
from_state.apply_migration_operations(&[create_clusters], "clusters");
let from_clusters = from_state
.find_model_by_table("clusters")
.expect("clusters model present in from_state");
assert!(
!from_clusters
.fields
.get("id")
.expect("id field in from_state")
.nullable,
"from_state PK FieldState.nullable must be false (column_def_to_field_state derives \
from not_null); got nullable=true"
);
let detector = MigrationAutodetector::new(from_state, to_state);
let direct_ops = detector.generate_operations();
let migrations = detector.generate_migrations();
let migration_ops: Vec<&super::super::Operation> = migrations
.iter()
.flat_map(|m| m.operations.iter())
.collect();
assert!(
!direct_ops.iter().any(|op| matches!(
op,
super::super::Operation::AlterColumn { column, .. } if column == "id"
)),
"generate_operations() emitted spurious AlterColumn for unchanged `id` PK \
under apply_migration_operations from_state. ops={:?}",
direct_ops
);
assert!(
!migration_ops.iter().any(|op| matches!(
op,
super::super::Operation::AlterColumn { column, .. } if column == "id"
)),
"generate_migrations() emitted spurious AlterColumn for unchanged `id` PK \
under apply_migration_operations from_state. ops={:?}",
migration_ops
);
}
#[rstest]
fn generate_migrations_emits_add_constraint_for_added_unique_together() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
let mut from_model = build_model_state(
"clusters",
"Cluster",
vec![id_field.clone(), org_field.clone(), name_field.clone()],
Vec::new(),
Vec::new(),
);
from_model.table_name = "clusters_cluster".to_string();
let unique_constraint = ConstraintDefinition {
name: "clusters_cluster_organization_id_name_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["organization_id".to_string(), "name".to_string()],
expression: None,
foreign_key_info: None,
};
let mut to_model = build_model_state(
"clusters",
"Cluster",
vec![id_field, org_field, name_field],
Vec::new(),
vec![unique_constraint],
);
to_model.table_name = "clusters_cluster".to_string();
let from_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let migrations = detector.generate_migrations();
assert_eq!(
migrations.len(),
1,
"expected exactly one Migration, got: {:?}",
migrations
);
assert_eq!(migrations[0].app_label, "clusters");
assert_eq!(
migrations[0].operations.len(),
1,
"expected exactly one operation in the migration, got: {:?}",
migrations[0].operations
);
let super::super::Operation::AddConstraint {
table,
constraint_sql,
} = &migrations[0].operations[0]
else {
panic!(
"expected Operation::AddConstraint, got: {:?}",
migrations[0].operations[0]
);
};
assert_eq!(table, "clusters_cluster");
assert!(
constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
"constraint SQL should carry the constraint name, got: {}",
constraint_sql
);
}
#[rstest]
fn generate_migrations_emits_drop_constraint_for_removed_unique_together() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
let unique_constraint = ConstraintDefinition {
name: "clusters_cluster_organization_id_name_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["organization_id".to_string(), "name".to_string()],
expression: None,
foreign_key_info: None,
};
let mut from_model = build_model_state(
"clusters",
"Cluster",
vec![id_field.clone(), org_field.clone(), name_field.clone()],
Vec::new(),
vec![unique_constraint],
);
from_model.table_name = "clusters_cluster".to_string();
let mut to_model = build_model_state(
"clusters",
"Cluster",
vec![id_field, org_field, name_field],
Vec::new(),
Vec::new(),
);
to_model.table_name = "clusters_cluster".to_string();
let from_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let migrations = detector.generate_migrations();
assert_eq!(
migrations.len(),
1,
"expected exactly one Migration, got: {:?}",
migrations
);
assert_eq!(migrations[0].app_label, "clusters");
assert_eq!(
migrations[0].operations.len(),
1,
"expected exactly one operation in the migration, got: {:?}",
migrations[0].operations
);
let super::super::Operation::DropConstraint {
table,
constraint_name,
} = &migrations[0].operations[0]
else {
panic!(
"expected Operation::DropConstraint, got: {:?}",
migrations[0].operations[0]
);
};
assert_eq!(table, "clusters_cluster");
assert_eq!(
constraint_name,
"clusters_cluster_organization_id_name_uniq"
);
}
#[rstest]
fn shared_per_app_emissions_are_consistent_between_generate_paths() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
let new_col = FieldState::new("region", super::super::FieldType::VarChar(64), false);
let mut from_model = build_model_state(
"clusters",
"Cluster",
vec![id_field.clone(), org_field.clone(), name_field.clone()],
Vec::new(),
Vec::new(),
);
from_model.table_name = "clusters_cluster".to_string();
let unique_constraint = ConstraintDefinition {
name: "clusters_cluster_organization_id_name_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["organization_id".to_string(), "name".to_string()],
expression: None,
foreign_key_info: None,
};
let mut to_model = build_model_state(
"clusters",
"Cluster",
vec![id_field, org_field, name_field, new_col],
Vec::new(),
vec![unique_constraint],
);
to_model.table_name = "clusters_cluster".to_string();
let from_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("clusters".to_string(), "Cluster".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let ops = detector.generate_operations();
let migrations = detector.generate_migrations();
let mig_ops: Vec<&super::super::Operation> = migrations
.iter()
.flat_map(|m| m.operations.iter())
.collect();
assert_eq!(
ops.len(),
mig_ops.len(),
"shared per-app emissions diverged between generate_operations() ({:?}) and generate_migrations() ({:?})",
ops,
mig_ops
);
for op in &ops {
assert!(
mig_ops.iter().any(|m| *m == op),
"generate_operations() produced {:?} but generate_migrations() did not",
op
);
}
for op in &mig_ops {
assert!(
ops.iter().any(|o| o == *op),
"generate_migrations() produced {:?} but generate_operations() did not",
op
);
}
}
#[rstest]
fn detect_added_composite_pk_does_not_double_emit_add_constraint() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let tenant_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
let from_model = build_model_state(
"billing",
"Invoice",
vec![id_field.clone(), tenant_field.clone()],
Vec::new(),
Vec::new(),
);
let composite_pk = ConstraintDefinition {
name: "billing_invoice_pkey".to_string(),
constraint_type: "primary_key".to_string(),
fields: vec!["id".to_string(), "tenant_id".to_string()],
expression: None,
foreign_key_info: None,
};
let to_model = build_model_state(
"billing",
"Invoice",
vec![id_field, tenant_field],
Vec::new(),
vec![composite_pk],
);
let from_state = build_project_state(vec![(
("billing".to_string(), "Invoice".to_string()),
from_model,
)]);
let to_state = build_project_state(vec![(
("billing".to_string(), "Invoice".to_string()),
to_model,
)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert_eq!(operations.len(), 1, "got: {:?}", operations);
assert!(
matches!(
&operations[0],
super::super::Operation::CreateCompositePrimaryKey { columns, .. }
if columns == &["id".to_string(), "tenant_id".to_string()]
),
"expected only CreateCompositePrimaryKey, got: {:?}",
operations
);
}
#[rstest]
fn inline_unique_param_on_from_side_does_not_emit_redundant_add_constraint() {
let mut username_field =
FieldState::new("username", super::super::FieldType::VarChar(150), false);
username_field
.params
.insert("unique".to_string(), "true".to_string());
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let from_model = build_model_state(
"users",
"User",
vec![id_field.clone(), username_field.clone()],
Vec::new(),
Vec::new(),
);
let synthesised = ConstraintDefinition {
name: "users_user_username_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["username".to_string()],
expression: None,
foreign_key_info: None,
};
let to_model = build_model_state(
"users",
"User",
vec![id_field, username_field],
Vec::new(),
vec![synthesised],
);
let from_state = build_project_state(vec![(
("users".to_string(), "User".to_string()),
from_model,
)]);
let to_state =
build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert!(
operations
.iter()
.all(|op| !matches!(op, super::super::Operation::AddConstraint { .. })),
"expected NO Operation::AddConstraint, got: {:?}",
operations
);
}
#[rstest]
fn single_field_unique_constraint_renames_do_not_emit_redundant_add_constraint() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let username_field =
FieldState::new("username", super::super::FieldType::VarChar(150), false);
let auto_named = ConstraintDefinition {
name: "sqlite_autoindex_users_1".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["username".to_string()],
expression: None,
foreign_key_info: None,
};
let model_named = ConstraintDefinition {
name: "users_user_username_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["username".to_string()],
expression: None,
foreign_key_info: None,
};
let from_model = build_model_state(
"users",
"User",
vec![id_field.clone(), username_field.clone()],
Vec::new(),
vec![auto_named],
);
let to_model = build_model_state(
"users",
"User",
vec![id_field, username_field],
Vec::new(),
vec![model_named],
);
let from_state = build_project_state(vec![(
("users".to_string(), "User".to_string()),
from_model,
)]);
let to_state =
build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
let constraint_ops: Vec<_> = operations
.iter()
.filter(|op| {
matches!(
op,
super::super::Operation::AddConstraint { .. }
| super::super::Operation::DropConstraint { .. }
)
})
.collect();
assert!(
constraint_ops.is_empty(),
"expected no Add/DropConstraint ops, got: {:?}",
constraint_ops
);
}
#[rstest]
fn from_side_unique_constraint_matched_by_inline_unique_on_to_side_emits_no_drop() {
let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
let mut username_field =
FieldState::new("username", super::super::FieldType::VarChar(150), false);
username_field
.params
.insert("unique".to_string(), "true".to_string());
let unique_constraint = ConstraintDefinition {
name: "users_user_username_uniq".to_string(),
constraint_type: "unique".to_string(),
fields: vec!["username".to_string()],
expression: None,
foreign_key_info: None,
};
let bare_username =
FieldState::new("username", super::super::FieldType::VarChar(150), false);
let from_model = build_model_state(
"users",
"User",
vec![id_field.clone(), bare_username],
Vec::new(),
vec![unique_constraint],
);
let to_model = build_model_state(
"users",
"User",
vec![id_field, username_field],
Vec::new(),
Vec::new(),
);
let from_state = build_project_state(vec![(
("users".to_string(), "User".to_string()),
from_model,
)]);
let to_state =
build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
let detector = MigrationAutodetector::new(from_state, to_state);
let operations = detector.generate_operations();
assert!(
operations
.iter()
.all(|op| !matches!(op, super::super::Operation::DropConstraint { .. })),
"expected NO Operation::DropConstraint, got: {:?}",
operations
);
}
#[rstest]
fn dedup_pass_drops_add_constraint_redundant_with_unique_add_column() {
let ops = vec![
super::super::Operation::AddColumn {
table: "users".to_string(),
column: super::super::ColumnDefinition {
name: "username".to_string(),
type_definition: super::super::FieldType::VarChar(150),
not_null: true,
unique: true,
primary_key: false,
auto_increment: false,
default: None,
},
mysql_options: None,
},
super::super::Operation::AddConstraint {
table: "users".to_string(),
constraint_sql: "CONSTRAINT users_user_username_uniq UNIQUE (username)".to_string(),
},
];
let mut by_app: std::collections::BTreeMap<String, Vec<super::super::Operation>> =
std::collections::BTreeMap::new();
by_app.insert("users".to_string(), ops);
MigrationAutodetector::dedup_redundant_unique_add_constraints(&mut by_app);
let remaining = &by_app["users"];
assert_eq!(
remaining.len(),
1,
"expected one operation after dedup, got: {:?}",
remaining
);
assert!(
matches!(remaining[0], super::super::Operation::AddColumn { .. }),
"expected the surviving op to be AddColumn, got: {:?}",
remaining[0]
);
}
}