Skip to main content

safe_migrate/rules/
destructive.rs

1// FILE: src/rules/destructive.rs
2use crate::analysis::mutations::{AlterTableActionMutation, Mutation};
3use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
4use crate::engine::config::Config;
5use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
6use crate::rules::Rule;
7
8pub struct CascadingDropRule;
9
10impl Rule for CascadingDropRule {
11    fn id(&self) -> &'static str {
12        "destructive-cascade"
13    }
14    fn default_tier(&self) -> ViolationTier {
15        ViolationTier::Tier1
16    }
17    fn recipe(&self) -> &'static str {
18        "Avoid CASCADE on DROP TABLE in production. Handle dependencies explicitly."
19    }
20
21    fn evaluate(
22        &self,
23        mutation: &Mutation,
24        result: &MutationResult,
25        _pre_state: &crate::analysis::state::PreState,
26        state: &AnalysisState,
27        _config: &Config,
28        cascade_closure: Option<&CascadeResult>,
29    ) -> Vec<Violation> {
30        if *result == MutationResult::Skipped {
31            return vec![];
32        }
33
34        let mut violations = Vec::new();
35
36        if let Mutation::DropTable(drop) = mutation
37            && drop.cascade
38            && let Some(closure) = cascade_closure
39        {
40            let mut affects_baseline = false;
41
42            for rel_id in &closure.dropped_relations {
43                if rel_id != &drop.id && state.baseline_relations.contains(rel_id) {
44                    affects_baseline = true;
45                    break;
46                }
47            }
48
49            if !affects_baseline {
50                for constraint in &closure.dropped_constraints {
51                    if state.baseline_foreign_keys.contains(constraint) {
52                        affects_baseline = true;
53                        break;
54                    }
55                }
56            }
57
58            if affects_baseline {
59                violations.push(Violation { source_range: None,
60                    rule_id: self.id(),
61                    operation_kind: OperationKind::DropTable,
62                    object_kind: ObjectKind::Table,
63                    object_name: drop.id.to_string(),
64                    tier: self.default_tier(),
65                    reason: format!(
66                        "DROP TABLE {} CASCADE silently destroys pre-existing database dependencies",
67                        drop.id
68                    ),
69                    recipe: self.recipe(),
70                    dedup_key: None,
71                            sql: None,
72                });
73            }
74        }
75        violations
76    }
77}
78
79pub struct SizeAwareAddColumnRule;
80
81impl Rule for SizeAwareAddColumnRule {
82    fn id(&self) -> &'static str {
83        "size-aware-add-column"
84    }
85    fn default_tier(&self) -> ViolationTier {
86        ViolationTier::Tier1
87    }
88    fn recipe(&self) -> &'static str {
89        "Adding a column with a default requires a table rewrite. For PG11+, constant defaults are safe. For volatiles or <PG11, use a multi-step backfill."
90    }
91
92    fn evaluate(
93        &self,
94        mutation: &Mutation,
95        result: &MutationResult,
96        pre_state: &crate::analysis::state::PreState,
97        state: &AnalysisState,
98        config: &Config,
99        _cascade_closure: Option<&CascadeResult>,
100    ) -> Vec<Violation> {
101        if *result == MutationResult::Skipped {
102            return vec![];
103        }
104
105        let mut violations = Vec::new();
106        let pg_version = state.pg_version_num.unwrap_or(config.assume_pg_version);
107
108        if let Mutation::AlterTable(alter) = mutation
109            && let AlterTableActionMutation::AddColumn {
110                default: Some(def), ..
111            } = &alter.action
112        {
113            let is_volatile = def.is_volatile();
114            let requires_rewrite = is_volatile || pg_version < 110000;
115
116            if requires_rewrite {
117                let (has_wide_columns, is_stale, rows) = match pre_state.relations.get(&alter.id) {
118                    Some(rel) => {
119                        let wide = rel.columns.iter().any(|c| {
120                            c.avg_width.unwrap_or(0) >= config.toast_width_threshold_bytes
121                        });
122                        // BUG FIX: Only mark as stale if it actually existed in the baseline database!
123                        // Tables created in this migration script are 0-rows fresh, not stale.
124                        let stale = rel.is_stale() && state.baseline_relations.contains(&alter.id);
125                        (
126                            wide,
127                            stale,
128                            rel.estimated_rows.unwrap_or(config.default_rows),
129                        )
130                    }
131                    None => {
132                        // Table is completely unknown (not in cache, not in migration). We are guessing. Mark as stale.
133                        (false, true, config.default_rows)
134                    }
135                };
136
137                if is_stale {
138                    let key = format!("{}_stale_{}", self.id(), alter.id);
139                    violations.push(Violation { source_range: None,
140                        rule_id: self.id(),
141                        operation_kind: OperationKind::AddColumn,
142                        object_kind: ObjectKind::Table,
143                        object_name: alter.id.to_string(),
144                        tier: ViolationTier::Tier2,
145                        reason: format!(
146                            "Table {} statistics are stale. Lock evaluations may be inaccurate.",
147                            alter.id
148                        ),
149                        recipe: "Run ANALYZE to ensure accurate TOAST width and row estimates before structural changes.",
150                        dedup_key: Some(key),
151                                    sql: None,
152                    });
153                }
154
155                let tier1_threshold = config.rule_tier1_threshold(self.id());
156                let mut tier = if rows >= tier1_threshold {
157                    ViolationTier::Tier1
158                } else {
159                    ViolationTier::Tier2
160                };
161
162                if has_wide_columns && tier == ViolationTier::Tier2 {
163                    tier = ViolationTier::Tier1;
164                }
165
166                let mut reason = if is_volatile {
167                    format!(
168                        "Adding column with volatile DEFAULT to {} triggers a table rewrite",
169                        alter.id
170                    )
171                } else {
172                    format!(
173                        "Adding column with DEFAULT to {} triggers a table rewrite on Postgres < 11",
174                        alter.id
175                    )
176                };
177
178                if has_wide_columns && tier == ViolationTier::Tier1 {
179                    reason.push_str(" (Escalated due to wide TOAST columns)");
180                }
181                if is_stale {
182                    reason.push_str(" [WARNING: Based on unknown offline statistics]");
183                }
184
185                violations.push(Violation {
186                    source_range: None,
187                    rule_id: self.id(),
188                    operation_kind: OperationKind::AddColumn,
189                    object_kind: ObjectKind::Table,
190                    object_name: alter.id.to_string(),
191                    tier,
192                    reason,
193                    recipe: self.recipe(),
194                    dedup_key: None,
195                    sql: None,
196                });
197            }
198        }
199        violations
200    }
201}
202
203pub struct DropDatabaseRule;
204
205impl Rule for DropDatabaseRule {
206    fn id(&self) -> &'static str {
207        "drop-database"
208    }
209    fn default_tier(&self) -> ViolationTier {
210        ViolationTier::Tier1
211    }
212    fn recipe(&self) -> &'static str {
213        "DROP DATABASE is an irreversible, high-blast-radius operation that destroys the entire database context."
214    }
215
216    fn evaluate(
217        &self,
218        mutation: &Mutation,
219        _result: &MutationResult,
220        _pre_state: &crate::analysis::state::PreState,
221        _state: &AnalysisState,
222        _config: &Config,
223        _cascade: Option<&CascadeResult>,
224    ) -> Vec<Violation> {
225        if let Mutation::DropDatabase(d) = mutation {
226            return vec![Violation {
227                source_range: None,
228                rule_id: self.id(),
229                operation_kind: OperationKind::DropDatabase,
230                object_kind: ObjectKind::Database,
231                object_name: d.id.to_string(),
232                tier: self.default_tier(),
233                reason: "DROP DATABASE detected".to_string(),
234                recipe: self.recipe(),
235                dedup_key: None,
236                sql: None,
237            }];
238        }
239        vec![]
240    }
241}
242
243pub struct DropSchemaCascadeRule;
244
245impl Rule for DropSchemaCascadeRule {
246    fn id(&self) -> &'static str {
247        "drop-schema-cascade"
248    }
249    fn default_tier(&self) -> ViolationTier {
250        ViolationTier::Tier1
251    }
252    fn recipe(&self) -> &'static str {
253        "DROP SCHEMA ... CASCADE recursively destroys every object in the schema. Handle dependencies explicitly."
254    }
255
256    fn evaluate(
257        &self,
258        mutation: &Mutation,
259        _result: &MutationResult,
260        _pre_state: &crate::analysis::state::PreState,
261        _state: &AnalysisState,
262        _config: &Config,
263        _cascade: Option<&CascadeResult>,
264    ) -> Vec<Violation> {
265        let mut violations = Vec::new();
266
267        if let Mutation::DropSchema(drop) = mutation
268            && drop.cascade
269        {
270            violations.push(Violation {
271                source_range: None,
272                rule_id: self.id(),
273                operation_kind: OperationKind::DropSchema,
274                object_kind: ObjectKind::Schema,
275                object_name: drop.names.join(", "),
276                tier: self.default_tier(),
277                reason: format!("DROP SCHEMA {} CASCADE detected", drop.names.join(", ")),
278                recipe: self.recipe(),
279                dedup_key: None,
280                sql: None,
281            });
282        }
283
284        violations
285    }
286}
287
288pub struct CreateTableAsSelectRule;
289
290impl Rule for CreateTableAsSelectRule {
291    fn id(&self) -> &'static str {
292        "create-table-as-select"
293    }
294    fn default_tier(&self) -> ViolationTier {
295        ViolationTier::Tier2
296    }
297    fn recipe(&self) -> &'static str {
298        "CREATE TABLE AS SELECT can be extremely slow and resource-intensive on large datasets. Consider creating the table first and using INSERT INTO ... SELECT in batches."
299    }
300
301    fn evaluate(
302        &self,
303        mutation: &Mutation,
304        result: &MutationResult,
305        _pre_state: &crate::analysis::state::PreState,
306        _state: &AnalysisState,
307        _config: &Config,
308        _cascade_closure: Option<&CascadeResult>,
309    ) -> Vec<Violation> {
310        if *result == MutationResult::Skipped {
311            return vec![];
312        }
313        if let Mutation::CreateTable(c) = mutation
314            && c.as_select
315        {
316            return vec![Violation {
317                source_range: None,
318                rule_id: self.id(),
319                operation_kind: OperationKind::CreateTable,
320                object_kind: ObjectKind::Table,
321                object_name: c.id.to_string(),
322                tier: self.default_tier(),
323                reason: format!("CREATE TABLE AS SELECT detected for {}", c.id),
324                recipe: self.recipe(),
325                dedup_key: None,
326                sql: None,
327            }];
328        }
329        vec![]
330    }
331}
332
333pub enum Reversibility {
334    Reversible,
335    ConditionallyReversible,
336    Irreversible,
337}
338
339pub fn classify(mutation: &Mutation) -> Reversibility {
340    match mutation {
341        Mutation::Rename(_) => Reversibility::Reversible,
342        Mutation::CreateIndex(_) | Mutation::CreateTable(_) => Reversibility::Reversible,
343        Mutation::AlterTable(a) => match &a.action {
344            AlterTableActionMutation::AddColumn { .. } => Reversibility::Reversible,
345            AlterTableActionMutation::DropColumn { .. } => Reversibility::Irreversible,
346            AlterTableActionMutation::SetType { .. } => Reversibility::ConditionallyReversible,
347            _ => Reversibility::Reversible,
348        },
349        Mutation::DropTable(_) | Mutation::DropDatabase(_) => Reversibility::Irreversible,
350        _ => Reversibility::ConditionallyReversible,
351    }
352}
353
354pub struct ReversibilityRule;
355
356impl Rule for ReversibilityRule {
357    fn id(&self) -> &'static str {
358        "irreversible-migration"
359    }
360    fn default_tier(&self) -> ViolationTier {
361        ViolationTier::Tier1
362    }
363    fn recipe(&self) -> &'static str {
364        "This operation is irreversible. Ensure backups are available."
365    }
366
367    fn evaluate(
368        &self,
369        mutation: &Mutation,
370        result: &MutationResult,
371        pre_state: &crate::analysis::state::PreState,
372        state: &AnalysisState,
373        config: &Config,
374        _cascade_closure: Option<&CascadeResult>,
375    ) -> Vec<Violation> {
376        if *result == MutationResult::Skipped {
377            return vec![];
378        }
379
380        let mut violations = Vec::new();
381
382        if let Mutation::AlterTable(a) = mutation
383            && let AlterTableActionMutation::SetType { column, ty, .. } = &a.action
384            && let Some(rel) = pre_state.relations.get(&a.id)
385            && let Some(old_ty) = rel.get_column(column).and_then(|c| c.data_type.as_ref())
386        {
387            // Only flag as "conditionally reversible" if there's actual data loss risk
388            // (e.g., narrowing, not widening). Table rewrites are handled by TypeChangeRewriteRule.
389            if is_type_change_lossy(old_ty, ty) {
390                let rows = rel.estimated_rows.unwrap_or(config.default_rows);
391                let tier = if rows >= config.rule_tier1_threshold(self.id()) {
392                    ViolationTier::Tier1
393                } else {
394                    ViolationTier::Tier2
395                };
396                violations.push(Violation {
397                    source_range: None,
398                    rule_id: self.id(),
399                    operation_kind: OperationKind::AlterColumnType,
400                    object_kind: ObjectKind::Table,
401                    object_name: a.id.to_string(),
402                    tier,
403                    reason: "Conditionally reversible type change detected".to_string(),
404                    recipe: "This type change may be lossy. Verify data compatibility.",
405                    dedup_key: None,
406                    sql: None,
407                });
408            }
409        }
410
411        if let Reversibility::Irreversible = classify(mutation) {
412            let mut rows = if let Mutation::AlterTable(a) = mutation {
413                pre_state
414                    .relations
415                    .get(&a.id)
416                    .and_then(|r| r.estimated_rows)
417                    .unwrap_or(config.default_rows)
418            } else if let Mutation::DropTable(d) = mutation {
419                pre_state
420                    .relations
421                    .get(&d.id)
422                    .and_then(|r| r.estimated_rows)
423                    .unwrap_or(config.default_rows)
424            } else {
425                config.default_rows
426            };
427
428            if let Mutation::AlterTable(a) = mutation
429                && let AlterTableActionMutation::DropColumn { name, .. } = &a.action
430                && state.column_was_added_in_transaction(&a.id, name)
431            {
432                rows = 0;
433            }
434
435            let tier = if rows == 0 {
436                ViolationTier::Tier3
437            } else {
438                ViolationTier::Tier1
439            };
440
441            // Determine operation_kind and object_name from the mutation
442            let (operation_kind, object_kind, object_name) = match mutation {
443                Mutation::AlterTable(a) => match &a.action {
444                    AlterTableActionMutation::DropColumn { .. } => (
445                        OperationKind::DropColumn,
446                        ObjectKind::Table,
447                        a.id.to_string(),
448                    ),
449                    _ => (
450                        OperationKind::Other("irreversible".to_string()),
451                        ObjectKind::Table,
452                        a.id.to_string(),
453                    ),
454                },
455                Mutation::DropTable(d) => (
456                    OperationKind::DropTable,
457                    ObjectKind::Table,
458                    d.id.to_string(),
459                ),
460                Mutation::DropDatabase(d) => (
461                    OperationKind::DropDatabase,
462                    ObjectKind::Database,
463                    d.id.to_string(),
464                ),
465                _ => (
466                    OperationKind::Other("irreversible".to_string()),
467                    ObjectKind::Unknown,
468                    "unknown".to_string(),
469                ),
470            };
471
472            violations.push(Violation {
473                source_range: None,
474                rule_id: self.id(),
475                operation_kind,
476                object_kind,
477                object_name,
478                tier,
479                reason: "Irreversible data-destructive operation detected".to_string(),
480                recipe: self.recipe(),
481                dedup_key: None,
482                sql: None,
483            });
484        }
485        violations
486    }
487}
488
489/// Checks if a type change represents actual data loss (narrowing), not just a rewrite.
490/// This is used by ReversibilityRule to distinguish between "safe widening" (e.g., INT->BIGINT)
491/// and genuinely lossy changes (e.g., BIGINT->INT, VARCHAR(255)->VARCHAR(50)).
492fn is_type_change_lossy(old_type: &str, new_type: &str) -> bool {
493    let old = old_type.to_lowercase().trim().to_string();
494    let new = new_type.to_lowercase().trim().to_string();
495
496    // Same type is trivially safe
497    if old == new {
498        return false;
499    }
500
501    // Extract base types (without parameters)
502    let old_base = old.split('(').next().unwrap_or(&old).trim();
503    let new_base = new.split('(').next().unwrap_or(&new).trim();
504
505    // VARCHAR narrowing check
506    if let (Some(old_lim), Some(new_lim)) =
507        (extract_varchar_limit(&old), extract_varchar_limit(&new))
508    {
509        // Both are varchar - check if narrowing
510        return new_lim < old_lim;
511    }
512
513    // Varchar to something smaller/narrower - check if target type can hold all values
514    let old_varchar_limit = extract_varchar_limit(&old);
515    if old_varchar_limit.is_some() {
516        // varchar -> text is safe (widening)
517        if new == "text" || new == "varchar" || new == "character varying" {
518            return false;
519        }
520        // varchar -> other types might be lossy
521        return true;
522    }
523
524    // Widening integer types are safe (though they require a rewrite)
525    // int2 (smallint) -> int4 -> int8 (bigint) are all safe
526    if let (Some(old_sz), Some(new_sz)) = (
527        integer_type_size_bits(old_base),
528        integer_type_size_bits(new_base),
529    ) {
530        // Narrowing is unsafe
531        return new_sz < old_sz;
532    }
533
534    // For other types, assume safe unless we have specific knowledge
535    false
536}
537
538/// Extracts the character limit from a varchar type string.
539/// Returns the limit in bytes (for comparison purposes).
540fn extract_varchar_limit(ty: &str) -> Option<i32> {
541    if ty.starts_with("varchar(") || ty.starts_with("character varying(") {
542        let paren_start = ty.find('(')?;
543        let paren_end = ty[paren_start..].find(')')?;
544        let num_str = &ty[paren_start + 1..paren_start + paren_end];
545        let limit: i32 = num_str.parse().ok()?;
546        Some(limit)
547    } else if ty == "varchar" || ty == "character varying" {
548        // VARCHAR without limit is like TEXT - unbounded
549        None
550    } else {
551        None
552    }
553}
554
555/// Returns the size of integer types in bits.
556fn integer_type_size_bits(ty: &str) -> Option<i32> {
557    match ty {
558        "smallint" | "int2" => Some(16),
559        "integer" | "int4" | "int" => Some(32),
560        "bigint" | "int8" => Some(64),
561        _ => None,
562    }
563}
564
565pub struct GeneralCascadeRule;
566
567impl Rule for GeneralCascadeRule {
568    fn id(&self) -> &'static str {
569        "destructive-general-cascade"
570    }
571    fn default_tier(&self) -> ViolationTier {
572        ViolationTier::Tier1
573    }
574    fn recipe(&self) -> &'static str {
575        "Using CASCADE on DROP operations can silently delete dependent objects. Explicitly drop dependencies to avoid accidental data loss."
576    }
577
578    fn evaluate(
579        &self,
580        mutation: &Mutation,
581        _result: &MutationResult,
582        _pre_state: &crate::analysis::state::PreState,
583        _state: &AnalysisState,
584        _config: &Config,
585        _cascade: Option<&CascadeResult>,
586    ) -> Vec<Violation> {
587        let cascade_info: Option<(OperationKind, ObjectKind, String)> = match mutation {
588            Mutation::DropView(d) if d.cascade => Some((
589                OperationKind::DropView,
590                ObjectKind::View,
591                d.ids
592                    .iter()
593                    .map(|id| id.to_string())
594                    .collect::<Vec<_>>()
595                    .join(", "),
596            )),
597            Mutation::DropMaterializedView(d) if d.cascade => Some((
598                OperationKind::DropMaterializedView,
599                ObjectKind::MaterializedView,
600                d.ids
601                    .iter()
602                    .map(|id| id.to_string())
603                    .collect::<Vec<_>>()
604                    .join(", "),
605            )),
606            Mutation::DropSequence(d) if d.cascade => Some((
607                OperationKind::DropSequence,
608                ObjectKind::Sequence,
609                d.ids
610                    .iter()
611                    .map(|id| id.to_string())
612                    .collect::<Vec<_>>()
613                    .join(", "),
614            )),
615            Mutation::DropDomain(d) if d.cascade => Some((
616                OperationKind::DropDomain,
617                ObjectKind::Domain,
618                d.ids
619                    .iter()
620                    .map(|id| id.to_string())
621                    .collect::<Vec<_>>()
622                    .join(", "),
623            )),
624            Mutation::DropFunction(d) if d.cascade => Some((
625                OperationKind::DropFunction,
626                ObjectKind::Function,
627                "function".to_string(),
628            )),
629            Mutation::DropProcedure(d) if d.cascade => Some((
630                OperationKind::DropProcedure,
631                ObjectKind::Procedure,
632                "procedure".to_string(),
633            )),
634            Mutation::DropPublication(d) if d.cascade => Some((
635                OperationKind::DropPublication,
636                ObjectKind::Publication,
637                d.names.join(", "),
638            )),
639            _ => None,
640        };
641
642        if let Some((operation_kind, object_kind, object_name)) = cascade_info {
643            return vec![Violation {
644                source_range: None,
645                rule_id: self.id(),
646                operation_kind,
647                object_kind,
648                object_name,
649                tier: self.default_tier(),
650                reason: "Destructive CASCADE operation detected".to_string(),
651                recipe: self.recipe(),
652                dedup_key: None,
653                sql: None,
654            }];
655        }
656        vec![]
657    }
658}
659
660pub struct TypeChangeRewriteRule;
661
662impl TypeChangeRewriteRule {
663    fn is_type_change_safe(old_type: &str, new_type: &str, pg_version: u32) -> bool {
664        let old = old_type.to_lowercase();
665        let new = new_type.to_lowercase();
666        if old == new {
667            return true;
668        }
669
670        let old_base = old.split('(').next().unwrap_or(&old).trim();
671        let new_base = new.split('(').next().unwrap_or(&new).trim();
672
673        if (old_base == "varchar" || old_base == "character varying")
674            && (new_base == "varchar" || new_base == "character varying" || new_base == "text")
675        {
676            if new == "text" || new == "varchar" || new == "character varying" {
677                return true;
678            }
679            if let Some(old_mod) = extract_type_modifier_from_type_string(&old)
680                && let Some(new_mod) = extract_type_modifier_from_type_string(&new)
681                && old_mod <= new_mod
682            {
683                return true;
684            }
685        }
686
687        if pg_version >= 120000
688            && (old_base == "numeric" || old_base == "decimal")
689            && (new_base == "numeric" || new_base == "decimal")
690            && !new.contains('(')
691        {
692            return true;
693        }
694
695        false
696    }
697
698    /// Detects whether a type change narrows a VARCHAR(n) column
699    /// using type_modifier values from the cache.
700    ///
701    /// atttypmod for VARCHAR(n) encodes the length limit:
702    ///   typmod = (limit + 4) for VARCHAR, so limit = typmod - 4
703    ///
704    /// A smaller typmod means a smaller limit, which is lossy.
705    /// Returns true if the new modifier represents a smaller limit than the old.
706    pub fn is_lossy_varchar_narrowing(
707        old_modifier: Option<i32>,
708        new_modifier: Option<i32>,
709    ) -> bool {
710        match (old_modifier, new_modifier) {
711            (Some(old), Some(new)) => new < old,
712            (None, Some(_)) => true,
713            _ => false,
714        }
715    }
716}
717
718/// Extracts a synthetic type_modifier-like value from a type string.
719/// Used when the new type comes from the migration SQL (not from the cache).
720/// For varchar(N) types, approximates the atttypmod value.
721pub fn extract_type_modifier_from_type_string(ty: &str) -> Option<i32> {
722    let lower = ty.to_lowercase().trim().to_string();
723    // Check for varchar(N) or character varying(N)
724    if lower.starts_with("varchar(") || lower.starts_with("character varying(") {
725        let paren_start = lower.find('(')?;
726        let paren_end = lower[paren_start..].find(')')?;
727        let num_str = &lower[paren_start + 1..paren_start + paren_end];
728        let limit: i32 = num_str.parse().ok()?;
729        // atttypmod = limit + 4 for varchar
730        Some(limit + 4)
731    } else {
732        None
733    }
734}
735
736impl Rule for TypeChangeRewriteRule {
737    fn id(&self) -> &'static str {
738        "type-change-rewrite"
739    }
740    fn default_tier(&self) -> ViolationTier {
741        ViolationTier::Tier1
742    }
743    fn recipe(&self) -> &'static str {
744        "Changing this column type requires an ACCESS EXCLUSIVE table rewrite. Add a new column, backfill, and swap."
745    }
746
747    fn evaluate(
748        &self,
749        mutation: &Mutation,
750        result: &MutationResult,
751        pre_state: &crate::analysis::state::PreState,
752        state: &AnalysisState,
753        config: &Config,
754        _cascade_closure: Option<&CascadeResult>,
755    ) -> Vec<Violation> {
756        if *result == MutationResult::Skipped {
757            return vec![];
758        }
759
760        let mut violations = Vec::new();
761
762        if let Mutation::AlterTable(alter) = mutation
763            && let AlterTableActionMutation::SetType {
764                column,
765                ty,
766                has_using: _,
767            } = &alter.action
768        {
769            let pg_version = state.pg_version_num.unwrap_or(config.assume_pg_version);
770
771            let (is_safe, rows, old_type_str, old_modifier) =
772                match pre_state.relations.get(&alter.id) {
773                    Some(rel) => {
774                        let col_info = rel.columns.iter().find(|c| c.name == *column);
775                        let old_ty = col_info.and_then(|col| col.data_type.as_ref());
776
777                        let safe = old_ty
778                            .map(|o| Self::is_type_change_safe(o, ty, pg_version))
779                            .unwrap_or(false);
780                        (
781                            safe,
782                            rel.estimated_rows.unwrap_or(config.default_rows),
783                            old_ty.cloned().unwrap_or_else(|| "unknown".to_string()),
784                            col_info.and_then(|col| col.type_modifier),
785                        )
786                    }
787                    None => (false, config.default_rows, "unknown".to_string(), None),
788                };
789
790            if !is_safe {
791                let tier1_threshold = config.rule_tier1_threshold(self.id());
792
793                let tier = if rows >= tier1_threshold {
794                    ViolationTier::Tier1
795                } else {
796                    ViolationTier::Tier2
797                };
798
799                let new_modifier = extract_type_modifier_from_type_string(ty);
800
801                if Self::is_lossy_varchar_narrowing(old_modifier, new_modifier) {
802                    violations.push(Violation { source_range: None,
803                        rule_id: self.id(),
804                        operation_kind: OperationKind::AlterColumnType,
805                        object_kind: ObjectKind::Table,
806                        object_name: format!("{}.{}", alter.id, column),
807                        tier,
808                        reason: format!(
809                            "Changing column {}.{} type from {} to {} narrows VARCHAR precision (lossy)",
810                            alter.id, column, old_type_str, ty
811                        ),
812                        recipe: "Narrowing VARCHAR(n) precision may cause data truncation. Consider adding a new column, backfilling, and then dropping the old one.",
813                        dedup_key: None,
814                                    sql: None,
815                    });
816                } else {
817                    violations.push(Violation {
818                        source_range: None,
819                        rule_id: self.id(),
820                        operation_kind: OperationKind::AlterColumnType,
821                        object_kind: ObjectKind::Table,
822                        object_name: format!("{}.{}", alter.id, column),
823                        tier,
824                        reason: format!(
825                            "Changing column {}.{} type from {} to {} causes a table rewrite",
826                            alter.id, column, old_type_str, ty
827                        ),
828                        recipe: self.recipe(),
829                        dedup_key: None,
830                        sql: None,
831                    });
832                }
833            }
834        }
835        violations
836    }
837}