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