use super::scanner::CodeReference;
use crate::ast::{Action, Qail};
use crate::migrate::Schema;
use std::collections::HashMap;
#[derive(Debug, Default)]
pub struct MigrationImpact {
pub breaking_changes: Vec<BreakingChange>,
pub warnings: Vec<Warning>,
pub safe_to_run: bool,
pub affected_files: usize,
}
#[derive(Debug)]
pub enum BreakingChange {
DroppedColumn {
table: String,
column: String,
references: Vec<CodeReference>,
},
DroppedTable {
table: String,
references: Vec<CodeReference>,
},
RenamedColumn {
table: String,
old_name: String,
new_name: String,
references: Vec<CodeReference>,
},
TypeChanged {
table: String,
column: String,
old_type: String,
new_type: String,
references: Vec<CodeReference>,
},
}
#[derive(Debug)]
pub enum Warning {
OrphanedReference {
table: String,
references: Vec<CodeReference>,
},
}
impl MigrationImpact {
pub fn analyze(
commands: &[Qail],
code_refs: &[CodeReference],
_old_schema: &Schema,
_new_schema: &Schema,
) -> Self {
let mut impact = MigrationImpact::default();
let mut table_refs: HashMap<String, Vec<&CodeReference>> = HashMap::new();
let mut column_refs: HashMap<(String, String), Vec<&CodeReference>> = HashMap::new();
for code_ref in code_refs {
table_refs
.entry(code_ref.table.clone())
.or_default()
.push(code_ref);
for col in &code_ref.columns {
column_refs
.entry((code_ref.table.clone(), col.clone()))
.or_default()
.push(code_ref);
}
}
for cmd in commands {
match cmd.action {
Action::Drop => {
let refs = cloned_refs_for_table(&table_refs, &cmd.table);
if !refs.is_empty() {
impact.breaking_changes.push(BreakingChange::DroppedTable {
table: cmd.table.clone(),
references: refs,
});
}
}
Action::AlterDrop => {
for col_expr in &cmd.columns {
if let crate::ast::Expr::Named(col_name) = col_expr {
let refs = cloned_refs_for_column(&column_refs, &cmd.table, col_name);
if !refs.is_empty() {
impact.breaking_changes.push(BreakingChange::DroppedColumn {
table: cmd.table.clone(),
column: col_name.clone(),
references: refs,
});
}
}
}
}
Action::Mod => {
let refs = cloned_refs_for_table(&table_refs, &cmd.table);
if !refs.is_empty() {
impact.breaking_changes.push(BreakingChange::RenamedColumn {
table: cmd.table.clone(),
old_name: "unknown".to_string(),
new_name: "unknown".to_string(),
references: refs,
});
}
}
_ => {}
}
}
let mut affected: std::collections::HashSet<_> = std::collections::HashSet::new();
for change in &impact.breaking_changes {
match change {
BreakingChange::DroppedColumn { references, .. }
| BreakingChange::DroppedTable { references, .. }
| BreakingChange::RenamedColumn { references, .. }
| BreakingChange::TypeChanged { references, .. } => {
for r in references {
affected.insert(r.file.clone());
}
}
}
}
impact.affected_files = affected.len();
impact.safe_to_run = impact.breaking_changes.is_empty();
impact
}
pub fn report(&self) -> String {
let mut output = String::new();
if self.safe_to_run {
output.push_str("✓ Migration is safe to run\n");
return output;
}
output.push_str("⚠️ BREAKING CHANGES DETECTED\n\n");
output.push_str(&format!("Affected files: {}\n\n", self.affected_files));
for change in &self.breaking_changes {
match change {
BreakingChange::DroppedColumn {
table,
column,
references,
} => {
output.push_str(&format!(
"DROP COLUMN {}.{} ({} references)\n",
table,
column,
references.len()
));
for r in references.iter().take(5) {
output.push_str(&format!(
" ❌ {}:{} → uses \"{}\" in {}\n",
r.file.display(),
r.line,
column, r.snippet
));
}
if references.len() > 5 {
output.push_str(&format!(" ... and {} more\n", references.len() - 5));
}
output.push('\n');
}
BreakingChange::DroppedTable { table, references } => {
output.push_str(&format!(
"DROP TABLE {} ({} references)\n",
table,
references.len()
));
for r in references.iter().take(5) {
output.push_str(&format!(
" ❌ {}:{} → {}\n",
r.file.display(),
r.line,
r.snippet
));
}
output.push('\n');
}
BreakingChange::RenamedColumn {
table,
old_name,
new_name,
references,
} => {
output.push_str(&format!(
"RENAME {}.{} → {} ({} references)\n",
table,
old_name,
new_name,
references.len()
));
for r in references.iter().take(5) {
output.push_str(&format!(
" ⚠️ {}:{} → {}\n",
r.file.display(),
r.line,
r.snippet
));
}
output.push('\n');
}
BreakingChange::TypeChanged {
table,
column,
old_type,
new_type,
references,
} => {
output.push_str(&format!(
"TYPE CHANGE {}.{}: {} → {} ({} references)\n",
table,
column,
old_type,
new_type,
references.len()
));
for r in references.iter().take(5) {
output.push_str(&format!(
" ⚠️ {}:{} → {}\n",
r.file.display(),
r.line,
r.snippet
));
}
output.push('\n');
}
}
}
output
}
}
fn cloned_refs_for_table(
table_refs: &HashMap<String, Vec<&CodeReference>>,
table: &str,
) -> Vec<CodeReference> {
let mut out = Vec::new();
for (ref_table, refs) in table_refs {
if table_name_matches(table, ref_table) {
push_unique_refs(&mut out, refs);
}
}
out
}
fn cloned_refs_for_column(
column_refs: &HashMap<(String, String), Vec<&CodeReference>>,
table: &str,
column: &str,
) -> Vec<CodeReference> {
let mut out = Vec::new();
let column = normalize_ident(column);
for ((ref_table, ref_column), refs) in column_refs {
let ref_column = normalize_ident(ref_column);
if table_name_matches(table, ref_table) && (ref_column == column || ref_column == "*") {
push_unique_refs(&mut out, refs);
}
}
out
}
fn push_unique_refs(out: &mut Vec<CodeReference>, refs: &[&CodeReference]) {
for reference in refs {
let duplicate = out.iter().any(|existing| {
existing.file == reference.file
&& existing.line == reference.line
&& existing.table == reference.table
&& existing.snippet == reference.snippet
});
if !duplicate {
out.push((*reference).clone());
}
}
}
fn table_name_matches(command_table: &str, ref_table: &str) -> bool {
let command_table = normalize_ident(command_table);
let ref_table = normalize_ident(ref_table);
if command_table == ref_table {
return true;
}
let command_has_schema = command_table.contains('.');
let ref_has_schema = ref_table.contains('.');
(!command_has_schema || !ref_has_schema)
&& bare_table_name(&command_table) == bare_table_name(&ref_table)
}
fn bare_table_name(table: &str) -> &str {
table.rsplit_once('.').map_or(table, |(_, bare)| bare)
}
fn normalize_ident(ident: &str) -> String {
ident
.split('.')
.map(|part| part.trim().trim_matches('"'))
.collect::<Vec<_>>()
.join(".")
.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_detect_dropped_table() {
let cmd = Qail {
action: Action::Drop,
table: "users".to_string(),
..Default::default()
};
let code_ref = CodeReference {
file: PathBuf::from("src/handlers.rs"),
line: 42,
table: "users".to_string(),
columns: vec!["name".to_string()],
query_type: super::super::scanner::QueryType::Qail,
snippet: "get users fields *".to_string(),
};
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &[code_ref], &old_schema, &new_schema);
assert!(!impact.safe_to_run);
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_schema_qualified_drop_matches_bare_raw_sql_reference() {
let cmd = Qail {
action: Action::AlterDrop,
table: "app.users".to_string(),
columns: vec![crate::ast::Expr::Named("old_email".to_string())],
..Default::default()
};
let code_ref = CodeReference {
file: PathBuf::from("src/reporting.ts"),
line: 17,
table: "users".to_string(),
columns: vec!["old_email".to_string()],
query_type: super::super::scanner::QueryType::RawSql,
snippet: r#"SELECT old_email FROM "app"."users""#.to_string(),
};
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &[code_ref], &old_schema, &new_schema);
assert!(!impact.safe_to_run);
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_schema_qualified_drop_ignores_different_schema_reference() {
let cmd = Qail {
action: Action::Drop,
table: r#""app"."users""#.to_string(),
..Default::default()
};
let code_ref = CodeReference {
file: PathBuf::from("src/admin.rs"),
line: 24,
table: "admin.users".to_string(),
columns: vec!["id".to_string()],
query_type: super::super::scanner::QueryType::RawSql,
snippet: r#"SELECT id FROM "admin"."users""#.to_string(),
};
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &[code_ref], &old_schema, &new_schema);
assert!(impact.safe_to_run);
assert_eq!(impact.breaking_changes.len(), 0);
}
}