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