use super::scanner::{CodeReference, QueryType};
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, Clone, Copy, Default, PartialEq, Eq)]
pub enum RawSqlImpactMode {
#[default]
WarnOnly,
ExperimentalImpact,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ImpactAnalysisOptions {
pub raw_sql: RawSqlImpactMode,
}
#[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>,
},
RawSqlUnverified {
references: Vec<CodeReference>,
},
}
impl MigrationImpact {
pub fn analyze(
commands: &[Qail],
code_refs: &[CodeReference],
_old_schema: &Schema,
_new_schema: &Schema,
) -> Self {
Self::analyze_with_options(
commands,
code_refs,
_old_schema,
_new_schema,
ImpactAnalysisOptions::default(),
)
}
pub fn analyze_with_options(
commands: &[Qail],
code_refs: &[CodeReference],
_old_schema: &Schema,
_new_schema: &Schema,
options: ImpactAnalysisOptions,
) -> 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();
let mut raw_sql_refs = Vec::new();
for code_ref in code_refs {
if matches!(code_ref.query_type, QueryType::RawSql) {
raw_sql_refs.push(code_ref.clone());
if matches!(options.raw_sql, RawSqlImpactMode::WarnOnly) {
continue;
}
}
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);
}
}
if !raw_sql_refs.is_empty() {
impact.warnings.push(Warning::RawSqlUnverified {
references: raw_sql_refs,
});
}
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("✓ No verified Qail AST breaking changes detected\n");
append_warning_report(&mut output, &self.warnings);
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');
}
}
}
append_warning_report(&mut output, &self.warnings);
output
}
}
fn append_warning_report(output: &mut String, warnings: &[Warning]) {
if warnings.is_empty() {
return;
}
output.push('\n');
output.push_str("WARNINGS\n\n");
for warning in warnings {
match warning {
Warning::OrphanedReference { table, references } => {
output.push_str(&format!(
"Orphaned reference to {} ({} references)\n",
table,
references.len()
));
}
Warning::RawSqlUnverified { references } => {
output.push_str(&format!(
"Raw SQL detected ({} references). Qail cannot prove migration safety for raw SQL; review manually or convert to native Qail AST for verified checks.\n",
references.len()
));
for r in references.iter().take(5) {
output.push_str(&format!(
" ⚠ {}:{} → {}\n",
r.file.display(),
r.line,
r.snippet
));
}
if references.len() > 5 {
output.push_str(&format!(" ... and {} more\n", references.len() - 5));
}
}
}
output.push('\n');
}
}
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;
fn scan_temp_source(prefix: &str, source: &str) -> Vec<CodeReference> {
let tmp_name = format!(
"{}_{}_{}.ts",
prefix,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(&path, source).expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
code_refs
}
fn analyze_with_raw_sql_diagnostics(
commands: &[Qail],
code_refs: &[CodeReference],
old_schema: &Schema,
new_schema: &Schema,
) -> MigrationImpact {
MigrationImpact::analyze_with_options(
commands,
code_refs,
old_schema,
new_schema,
ImpactAnalysisOptions {
raw_sql: RawSqlImpactMode::ExperimentalImpact,
},
)
}
#[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_default_raw_sql_reference_warns_without_blocking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let code_ref = CodeReference {
file: PathBuf::from("src/reporting.ts"),
line: 17,
table: "users".to_string(),
columns: vec!["email".to_string()],
query_type: super::super::scanner::QueryType::RawSql,
snippet: "SELECT email FROM 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);
assert_eq!(impact.warnings.len(), 1);
assert!(
matches!(
&impact.warnings[0],
Warning::RawSqlUnverified { references } if references.len() == 1
),
"{:?}",
impact.warnings
);
assert!(
impact
.report()
.contains("Qail cannot prove migration safety for raw SQL")
);
}
#[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 =
analyze_with_raw_sql_diagnostics(&[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 =
analyze_with_raw_sql_diagnostics(&[cmd], &[code_ref], &old_schema, &new_schema);
assert!(impact.safe_to_run);
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_filter_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_filter_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"const q = "get users fields id where email = $1";"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run);
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_native_qail_qualified_join_target_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("id".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_native_join_target_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"const q = "get users join posts on users.id = posts.user_id fields users.id";"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_native_qail_join_source_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "posts".to_string(),
columns: vec![crate::ast::Expr::Named("user_id".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_native_join_source_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"const q = "get users join posts on users.id = posts.user_id fields users.id";"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_native_qail_merge_target_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_native_merge_target_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const q = `
merge users as u using staging_users as s on u.id = s.id
when matched then update set email = s.email
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_native_qail_merge_target_alias_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("id".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_native_merge_target_alias_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const q = `
merge users as u using staging_users as s on u.id = s.id
when matched then update set email = s.email
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_native_qail_merge_source_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "staging_users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_native_merge_source_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const q = `
merge users as u using staging_users as s on u.id = s.id
when matched then update set email = s.email
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_filter_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_filter_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"const sql = "SELECT id FROM users WHERE email = $1";"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run);
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_column_named_like_collation_is_not_blocked_by_raw_sql_collation() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("C".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_collation_not_column",
r#"
const sql = `
SELECT id
FROM users
WHERE lower(name COLLATE "C") LIKE $1 ESCAPE '\'
AND created_at > CURRENT_TIMESTAMP - INTERVAL '1 day'
ORDER BY EXTRACT(EPOCH FROM created_at)
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_column_named_like_schema_function_is_not_blocked() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("lower".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_schema_function_not_column",
r#"
const sql = `
SELECT pg_catalog.lower(email)
FROM users
WHERE pg_catalog.lower(status) = $1
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_column_named_like_projection_alias_without_as_is_not_blocked() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email_lower".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_projection_alias_without_as_not_column",
r#"
const sql = `
SELECT lower(email) email_lower, id user_id
FROM users
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_column_named_like_cast_type_is_not_blocked() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email_text".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_cast_type_not_column",
r#"
const sql = `
SELECT CAST(email AS public.email_text)
FROM users
WHERE status::public.status_name = $1
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_raw_sql_cte_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_cte_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
WITH active_users AS (
SELECT id, email FROM users WHERE status = $1
)
SELECT id FROM active_users
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_joined_table_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("total".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_join_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
SELECT u.id, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = $1
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_select_star_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_select_star_column",
r#"
const sql = `
SELECT *
FROM users
WHERE active = true
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_all_star_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_all_star_column",
r#"
const sql = `
SELECT ALL *
FROM users
WHERE active = true
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_distinct_on_star_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_distinct_on_star_column",
r#"
const sql = `
SELECT DISTINCT ON (tenant_id) *
FROM users
WHERE active = true
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_join_select_star_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("total".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_join_select_star_column",
r#"
const sql = `
SELECT *
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = $1
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_distinct_on_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("tenant_id".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_distinct_on_column",
r#"
const sql = `
SELECT DISTINCT ON (tenant_id) id
FROM users
WHERE status = $1
ORDER BY tenant_id, created_at DESC
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_filter_projection_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("active".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_filter_projection_column",
r#"
const sql = `
SELECT COUNT(*) FILTER (WHERE active) AS active_count
FROM users
WHERE status = $1
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_grouping_sets_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("status".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_grouping_sets_column",
r#"
const sql = `
SELECT tenant_id, status, count(*)
FROM orders
GROUP BY GROUPING SETS ((tenant_id, status), (tenant_id), ())
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_rollup_cube_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("channel".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_rollup_cube_column",
r#"
const sql = `
SELECT region, product, sum(total)
FROM orders
GROUP BY ROLLUP(region, product), CUBE(channel, status)
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_tablesample_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("active".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_tablesample_column",
r#"
const sql = `
SELECT id
FROM users TABLESAMPLE BERNOULLI(10) REPEATABLE (42)
WHERE active = true
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_tablesample_join_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("user_id".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_tablesample_join_column",
r#"
const sql = `
SELECT u.id, o.total
FROM users TABLESAMPLE SYSTEM (25) u
JOIN orders TABLESAMPLE BERNOULLI(10) o ON o.user_id = u.id
WHERE o.status = $1
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_only_inheritance_star_alias_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("active".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_only_star_alias_column",
r#"
const sql = `
SELECT u.id
FROM ONLY users * AS u
WHERE u.active = true
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_rows_from_join_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("active".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_rows_from_join_column",
r#"
const sql = `
SELECT u.id
FROM ROWS FROM (jsonb_to_recordset($1) AS (id int)) AS r(id)
JOIN users u ON u.id = r.id
WHERE u.active = true
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_update_from_source_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "payments".to_string(),
columns: vec![crate::ast::Expr::Named("state".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_update_from_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
UPDATE orders o
SET status = p.status
FROM payments p
WHERE o.payment_id = p.id
AND p.state = $1
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_target_column_does_not_match_qualified_update_from_source_column() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("state".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_update_from_source_false_target_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
UPDATE orders o
SET status = p.status
FROM payments p
WHERE o.payment_id = p.id
AND p.state = $1
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_raw_sql_update_from_unqualified_source_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "payments".to_string(),
columns: vec![crate::ast::Expr::Named("state".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_update_from_unqualified_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
UPDATE orders
SET status = state
FROM payments
WHERE orders.payment_id = payments.id
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_delete_using_unqualified_source_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("disabled".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_delete_using_unqualified_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
DELETE FROM sessions
USING users
WHERE sessions.user_id = id
AND disabled = true
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_target_column_does_not_match_qualified_delete_using_source_column() {
let cmd = Qail {
action: Action::AlterDrop,
table: "sessions".to_string(),
columns: vec![crate::ast::Expr::Named("disabled".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_delete_using_source_false_target_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
DELETE FROM sessions s
USING users u
WHERE s.user_id = u.id
AND u.disabled = true
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_raw_sql_insert_select_source_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("total".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_insert_select_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
INSERT INTO archived_orders (id, total)
SELECT id, total FROM orders WHERE status = $1
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_set_operation_rhs_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("status".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_set_operation_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
SELECT id FROM users
UNION ALL
SELECT user_id FROM orders WHERE status = $1
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_insert_alias_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_insert_alias_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
INSERT INTO users AS u (email)
VALUES ($1)
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_insert_alias_returning_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("created_at".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_insert_alias_returning_column",
r#"
const sql = `
INSERT INTO users AS u (email)
VALUES ($1)
RETURNING u.id, u.created_at
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_insert_alias_conflict_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("active".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_insert_alias_conflict_column",
r#"
const sql = `
INSERT INTO users AS u (email)
VALUES ($1)
ON CONFLICT (email)
DO UPDATE SET last_seen = EXCLUDED.last_seen
WHERE u.active
RETURNING u.id
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_update_only_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_update_only_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
UPDATE ONLY users
SET email = $1
WHERE id = $2
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_delete_only_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_delete_only_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
DELETE FROM ONLY users
WHERE email = $1
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_insert_returning_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("created_at".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_insert_returning_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
INSERT INTO users (email)
VALUES ($1)
RETURNING id, created_at
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_returning_alias_is_not_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("user_id".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_returning_alias_not_column",
r#"
const sql = `
INSERT INTO users (email)
VALUES ($1)
RETURNING id AS user_id
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_raw_sql_returning_alias_without_as_is_not_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("user_id".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_returning_alias_without_as_not_column",
r#"
const sql = `
INSERT INTO users (email)
VALUES ($1)
RETURNING id user_id
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_raw_sql_returning_old_new_alias_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_returning_old_new_alias_column",
r#"
const sql = `
UPDATE users
SET status = $1
WHERE id = $2
RETURNING WITH (OLD AS o, NEW AS n) o.email AS old_email, n.updated_at AS changed_at
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_merge_source_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "staging_orders".to_string(),
columns: vec![crate::ast::Expr::Named("status".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_merge_source_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
MERGE INTO orders o
USING staging_orders s
ON o.id = s.id
WHEN MATCHED THEN UPDATE SET status = s.status
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_merge_target_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("status".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_merge_target_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
MERGE INTO orders o
USING staging_orders s
ON o.id = s.id
WHEN MATCHED THEN UPDATE SET status = s.status
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_merge_returning_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("created_at".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_merge_returning_column",
r#"
const sql = `
MERGE INTO orders o
USING staging_orders s
ON o.id = s.id
WHEN MATCHED THEN UPDATE SET status = s.status
RETURNING created_at AS merged_at
`;
"#,
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_native_qail_subquery_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("total".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_native_subquery_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"const q = "get users fields id where exists (get orders fields user_id where total > $1)";"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = MigrationImpact::analyze(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_nested_subquery_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("total".to_string())],
..Default::default()
};
let tmp_name = format!(
"qail_impact_raw_sql_nested_subquery_column_{}_{}.ts",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let path = std::env::temp_dir().join(tmp_name);
std::fs::write(
&path,
r#"
const sql = `
SELECT id
FROM users
WHERE id IN (
SELECT user_id FROM orders WHERE total > $1
)
`;
"#,
)
.expect("write temp source");
let code_refs = super::super::scanner::CodebaseScanner::new().scan(&path);
let _ = std::fs::remove_file(&path);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_truncate_table_is_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "users".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_truncate_table",
"const sql = `TRUNCATE TABLE users`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_multi_truncate_table_is_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "orders".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_multi_truncate_table",
"const sql = `TRUNCATE TABLE users, orders CASCADE`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_copy_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_copy_column",
"const sql = `COPY users (email, status) FROM STDIN`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_lock_table_is_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "users".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_lock_table",
"const sql = `LOCK TABLE users IN ACCESS EXCLUSIVE MODE`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_create_index_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_create_index_column",
"const sql = `CREATE INDEX users_email_idx ON users (email) WHERE active = true`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_create_index_operator_class_name_is_not_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("jsonb_path_ops".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_create_index_operator_class_not_column",
"const sql = `CREATE INDEX users_payload_idx ON users USING gin (payload jsonb_path_ops)`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_raw_sql_create_view_source_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("active".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_create_view_source_column",
"const sql = `CREATE VIEW active_users AS SELECT id FROM users WHERE active = true`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_alter_fk_referenced_table_is_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "orgs".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_alter_fk_referenced_table",
"const sql = `ALTER TABLE users ADD CONSTRAINT users_org_fk FOREIGN KEY (org_id) REFERENCES orgs(id)`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_comment_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("email".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_comment_column",
"const sql = `COMMENT ON COLUMN users.email IS 'legacy email'`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_grant_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("status".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_grant_column",
"const sql = `GRANT SELECT (email), UPDATE (status) ON TABLE users TO app_role`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_multi_table_grant_is_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "orders".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_multi_table_grant",
"const sql = `GRANT SELECT ON TABLE users, orders TO app_role`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_non_table_privilege_target_is_not_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "schema".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_non_table_privilege_target",
"const sql = `GRANT USAGE ON SCHEMA public TO app_role`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 0);
}
#[test]
fn test_dropped_raw_sql_copy_where_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("active".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_copy_where_column",
"const sql = `COPY users (email) FROM STDIN WHERE active = true`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_maintenance_table_is_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "users".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_maintenance_table",
"const sql = `VACUUM (VERBOSE, ANALYZE) users (deleted_at)`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_refresh_view_is_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "active_users".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_refresh_view",
"const sql = `REFRESH MATERIALIZED VIEW CONCURRENTLY active_users`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_drop_table_reference_is_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "users".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_drop_table_reference",
"const sql = `DROP TABLE IF EXISTS users, orders CASCADE`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_create_trigger_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "orders".to_string(),
columns: vec![crate::ast::Expr::Named("status".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_create_trigger_column",
"const sql = `CREATE TRIGGER order_status_changed BEFORE UPDATE OF status ON orders FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status) EXECUTE FUNCTION audit_order()`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_publication_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("active".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_publication_column",
"const sql = `CREATE PUBLICATION tenant_pub FOR TABLE users (email) WHERE (active)`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_create_table_referenced_table_is_breaking() {
let cmd = Qail {
action: Action::Drop,
table: "orgs".to_string(),
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_create_table_referenced_table",
"const sql = `CREATE TABLE invoices (org_id uuid REFERENCES orgs(id))`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
#[test]
fn test_dropped_raw_sql_alter_policy_column_is_breaking() {
let cmd = Qail {
action: Action::AlterDrop,
table: "users".to_string(),
columns: vec![crate::ast::Expr::Named("tenant_id".to_string())],
..Default::default()
};
let code_refs = scan_temp_source(
"qail_impact_raw_sql_alter_policy_column",
"const sql = `ALTER POLICY tenant_users ON users USING (tenant_id = current_setting('app.tenant_id')::uuid)`;",
);
let old_schema = Schema::new();
let new_schema = Schema::new();
let impact = analyze_with_raw_sql_diagnostics(&[cmd], &code_refs, &old_schema, &new_schema);
assert!(!impact.safe_to_run, "{code_refs:?}");
assert_eq!(impact.breaking_changes.len(), 1);
}
}