Skip to main content

safe_migrate/rules/
destructive.rs

1use crate::analysis::mutations::{AlterTableActionMutation, Mutation};
2use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
3use crate::engine::config::Config;
4use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
5use crate::rules::Rule;
6
7pub const IRREVERSIBLE_MIGRATION_RULE_ID: &str = "irreversible-migration";
8
9pub struct CascadingDropRule;
10
11impl Rule for CascadingDropRule {
12    fn id(&self) -> &'static str {
13        "destructive-cascade"
14    }
15    fn default_tier(&self) -> ViolationTier {
16        ViolationTier::Tier1
17    }
18    fn recipe(&self) -> &'static str {
19        "Avoid CASCADE on DROP TABLE in production. Handle dependencies explicitly."
20    }
21
22    fn evaluate(
23        &self,
24        mutation: &Mutation,
25        result: &MutationResult,
26        _pre_state: &crate::analysis::state::PreState,
27        state: &AnalysisState,
28        _config: &Config,
29        cascade_closure: Option<&CascadeResult>,
30    ) -> Vec<Violation> {
31        if *result == MutationResult::Skipped {
32            return vec![];
33        }
34
35        let mut violations = Vec::new();
36
37        if let Mutation::DropTable(drop) = mutation
38            && drop.cascade
39            && let Some(closure) = cascade_closure
40        {
41            let mut affects_baseline = false;
42            let mut has_fk_pulled = false;
43
44            for rel_id in &closure.dropped_relations {
45                if rel_id != &drop.id && state.baseline_relations.contains(rel_id) {
46                    affects_baseline = true;
47                    if state.baseline_fk_dependencies.contains(rel_id) {
48                        has_fk_pulled = true;
49                    }
50                }
51            }
52
53            if !affects_baseline {
54                for (from_table, cname) in &closure.dropped_constraints {
55                    if state
56                        .baseline_foreign_keys
57                        .contains(&(from_table.clone(), cname.clone()))
58                    {
59                        affects_baseline = true;
60                        break;
61                    }
62                }
63            }
64
65            for (from_table, _cname) in &closure.dropped_constraints {
66                if state.baseline_fk_dependencies.contains(from_table) {
67                    has_fk_pulled = true;
68                }
69            }
70
71            if affects_baseline {
72                let mut reason = format!(
73                    "DROP TABLE {} CASCADE silently destroys pre-existing database dependencies",
74                    drop.id
75                );
76                if has_fk_pulled {
77                    reason.push_str(
78                        " (includes FK-pulled tables from other schemas — cross-team impact)",
79                    );
80                }
81                violations.push(Violation {
82                    source_range: None,
83                    rule_id: self.id(),
84                    operation_kind: OperationKind::DropTable,
85                    object_kind: ObjectKind::Table,
86                    object_name: drop.id.to_string(),
87                    tier: self.default_tier(),
88                    reason,
89                    recipe: self.recipe(),
90                    dedup_key: None,
91                    sql: None,
92                    fk_dependency_related: has_fk_pulled,
93                });
94            }
95        }
96        violations
97    }
98}
99
100pub struct SizeAwareAddColumnRule;
101
102impl Rule for SizeAwareAddColumnRule {
103    fn id(&self) -> &'static str {
104        "size-aware-add-column"
105    }
106    fn default_tier(&self) -> ViolationTier {
107        ViolationTier::Tier1
108    }
109    fn recipe(&self) -> &'static str {
110        "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."
111    }
112
113    fn evaluate(
114        &self,
115        mutation: &Mutation,
116        result: &MutationResult,
117        pre_state: &crate::analysis::state::PreState,
118        state: &AnalysisState,
119        config: &Config,
120        _cascade_closure: Option<&CascadeResult>,
121    ) -> Vec<Violation> {
122        if *result == MutationResult::Skipped {
123            return vec![];
124        }
125
126        let mut violations = Vec::new();
127        let pg_version = state.pg_version_num.unwrap_or(config.assume_pg_version);
128
129        if let Mutation::AlterTable(alter) = mutation
130            && let AlterTableActionMutation::AddColumn {
131                default: Some(def), ..
132            } = &alter.action
133        {
134            let is_volatile = def.is_volatile();
135            let requires_rewrite = is_volatile || pg_version < 110000;
136
137            if requires_rewrite {
138                let (has_wide_columns, is_stale, rows) = match pre_state.relations.get(&alter.id) {
139                    Some(rel) => {
140                        let wide = rel.columns.iter().any(|c| {
141                            c.avg_width.unwrap_or(0) >= config.toast_width_threshold_bytes
142                        });
143                        // Only cache-backed relations have meaningful statistics age.
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                        // Missing relation metadata uses the conservative fallback.
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_RULE_ID
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::Applied {
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            // DropDatabaseRule owns this finding.
439            if matches!(mutation, Mutation::DropDatabase(_)) {
440                return violations;
441            }
442            let mut rows = if let Mutation::AlterTable(a) = mutation {
443                pre_state
444                    .relations
445                    .get(&a.id)
446                    .and_then(|r| r.estimated_rows)
447                    .unwrap_or(config.default_rows)
448            } else if let Mutation::DropTable(d) = mutation {
449                pre_state
450                    .relations
451                    .get(&d.id)
452                    .and_then(|r| r.estimated_rows)
453                    .unwrap_or(config.default_rows)
454            } else {
455                config.default_rows
456            };
457
458            if let Mutation::AlterTable(a) = mutation
459                && let AlterTableActionMutation::DropColumn { name, .. } = &a.action
460                && state.column_was_added_in_transaction(&a.id, name)
461            {
462                rows = 0;
463            }
464
465            let tier = if rows == 0 {
466                ViolationTier::Tier3
467            } else {
468                ViolationTier::Tier1
469            };
470
471            // Determine operation_kind and object_name from the mutation
472            let (operation_kind, object_kind, object_name) = match mutation {
473                Mutation::AlterTable(a) => match &a.action {
474                    AlterTableActionMutation::DropColumn { .. } => (
475                        OperationKind::DropColumn,
476                        ObjectKind::Table,
477                        a.id.to_string(),
478                    ),
479                    _ => (
480                        OperationKind::Irreversible,
481                        ObjectKind::Table,
482                        a.id.to_string(),
483                    ),
484                },
485                Mutation::DropTable(d) => (
486                    OperationKind::DropTable,
487                    ObjectKind::Table,
488                    d.id.to_string(),
489                ),
490                Mutation::DropDatabase(d) => (
491                    OperationKind::DropDatabase,
492                    ObjectKind::Database,
493                    d.id.to_string(),
494                ),
495                _ => (
496                    OperationKind::Irreversible,
497                    ObjectKind::Table,
498                    "unknown".to_string(),
499                ),
500            };
501
502            violations.push(Violation {
503                source_range: None,
504                rule_id: self.id(),
505                operation_kind,
506                object_kind,
507                object_name,
508                tier,
509                reason: "Irreversible data-destructive operation detected".to_string(),
510                recipe: self.recipe(),
511                dedup_key: None,
512                sql: None,
513                fk_dependency_related: false,
514            });
515        }
516        violations
517    }
518}
519
520/// Checks if a type change represents actual data loss (narrowing), not just a rewrite.
521/// This is used by ReversibilityRule to distinguish between "safe widening" (e.g., INT->BIGINT)
522/// and genuinely lossy changes (e.g., BIGINT->INT, VARCHAR(255)->VARCHAR(50), TEXT->VARCHAR(n)).
523fn is_type_change_lossy(old_type: &str, new_type: &str) -> bool {
524    let old = old_type.to_lowercase().trim().to_string();
525    let new = new_type.to_lowercase().trim().to_string();
526
527    // Same type is trivially safe
528    if old == new {
529        return false;
530    }
531
532    // Extract base types (without parameters)
533    let old_base = old.split('(').next().unwrap_or(&old).trim();
534    let new_base = new.split('(').next().unwrap_or(&new).trim();
535
536    // VARCHAR narrowing check
537    if let (Some(old_lim), Some(new_lim)) =
538        (extract_varchar_limit(&old), extract_varchar_limit(&new))
539    {
540        // Both are varchar - check if narrowing
541        return new_lim < old_lim;
542    }
543
544    // Check for TEXT -> VARCHAR(n) narrowing (text is unbounded, varchar(n) is bounded)
545    // Also handles character varying (unbounded) -> varchar(n)
546    let new_varchar_limit = extract_varchar_limit(&new);
547    if new_varchar_limit.is_some()
548        && (old_base == "text" || old_base == "varchar" || old_base == "character varying")
549    {
550        return true;
551    }
552
553    // Varchar to something smaller/narrower - check if target type can hold all values
554    let old_varchar_limit = extract_varchar_limit(&old);
555    if old_varchar_limit.is_some() {
556        // varchar -> text is safe (widening)
557        if new == "text" || new == "varchar" || new == "character varying" {
558            return false;
559        }
560        // varchar -> other types might be lossy
561        return true;
562    }
563
564    // Widening integer types are safe (though they require a rewrite)
565    // int2 (smallint) -> int4 -> int8 (bigint) are all safe
566    if let (Some(old_sz), Some(new_sz)) = (
567        integer_type_size_bits(old_base),
568        integer_type_size_bits(new_base),
569    ) {
570        // Narrowing is unsafe
571        return new_sz < old_sz;
572    }
573
574    // For other types, assume safe unless we have specific knowledge
575    false
576}
577
578/// Extracts the character limit from a varchar type string.
579/// Returns the limit in bytes (for comparison purposes).
580fn extract_varchar_limit(ty: &str) -> Option<i32> {
581    if ty.starts_with("varchar(") || ty.starts_with("character varying(") {
582        let paren_start = ty.find('(')?;
583        let paren_end = ty[paren_start..].find(')')?;
584        let num_str = &ty[paren_start + 1..paren_start + paren_end];
585        let limit: i32 = num_str.parse().ok()?;
586        Some(limit)
587    } else if ty == "varchar" || ty == "character varying" {
588        // VARCHAR without limit is like TEXT - unbounded
589        None
590    } else {
591        None
592    }
593}
594
595/// Returns the size of integer types in bits.
596fn integer_type_size_bits(ty: &str) -> Option<i32> {
597    match ty {
598        "smallint" | "int2" => Some(16),
599        "integer" | "int4" | "int" => Some(32),
600        "bigint" | "int8" => Some(64),
601        _ => None,
602    }
603}
604
605pub struct GeneralCascadeRule;
606
607impl Rule for GeneralCascadeRule {
608    fn id(&self) -> &'static str {
609        "destructive-general-cascade"
610    }
611    fn default_tier(&self) -> ViolationTier {
612        ViolationTier::Tier1
613    }
614    fn recipe(&self) -> &'static str {
615        "Using CASCADE on DROP operations can silently delete dependent objects. Explicitly drop dependencies to avoid accidental data loss."
616    }
617
618    fn evaluate(
619        &self,
620        mutation: &Mutation,
621        _result: &MutationResult,
622        _pre_state: &crate::analysis::state::PreState,
623        _state: &AnalysisState,
624        _config: &Config,
625        _cascade: Option<&CascadeResult>,
626    ) -> Vec<Violation> {
627        let cascade_info: Option<(OperationKind, ObjectKind, String)> = match mutation {
628            Mutation::DropView(d) if d.cascade => Some((
629                OperationKind::DropView,
630                ObjectKind::View,
631                d.ids
632                    .iter()
633                    .map(|id| id.to_string())
634                    .collect::<Vec<_>>()
635                    .join(", "),
636            )),
637            Mutation::DropMaterializedView(d) if d.cascade => Some((
638                OperationKind::DropMaterializedView,
639                ObjectKind::MaterializedView,
640                d.ids
641                    .iter()
642                    .map(|id| id.to_string())
643                    .collect::<Vec<_>>()
644                    .join(", "),
645            )),
646            Mutation::DropSequence(d) if d.cascade => Some((
647                OperationKind::DropSequence,
648                ObjectKind::Sequence,
649                d.ids
650                    .iter()
651                    .map(|id| id.to_string())
652                    .collect::<Vec<_>>()
653                    .join(", "),
654            )),
655            Mutation::DropDomain(d) if d.cascade => Some((
656                OperationKind::DropDomain,
657                ObjectKind::Domain,
658                d.ids
659                    .iter()
660                    .map(|id| id.to_string())
661                    .collect::<Vec<_>>()
662                    .join(", "),
663            )),
664            Mutation::DropFunction(d) if d.cascade => Some((
665                OperationKind::DropFunction,
666                ObjectKind::Function,
667                "function".to_string(),
668            )),
669            Mutation::DropProcedure(d) if d.cascade => Some((
670                OperationKind::DropProcedure,
671                ObjectKind::Procedure,
672                "procedure".to_string(),
673            )),
674            Mutation::DropPublication(d) if d.cascade => Some((
675                OperationKind::DropPublication,
676                ObjectKind::Publication,
677                d.names.join(", "),
678            )),
679            _ => None,
680        };
681
682        if let Some((operation_kind, object_kind, object_name)) = cascade_info {
683            return vec![Violation {
684                source_range: None,
685                rule_id: self.id(),
686                operation_kind,
687                object_kind,
688                object_name,
689                tier: self.default_tier(),
690                reason: "Destructive CASCADE operation detected".to_string(),
691                recipe: self.recipe(),
692                dedup_key: None,
693                sql: None,
694                fk_dependency_related: false,
695            }];
696        }
697        vec![]
698    }
699}
700
701pub struct TypeChangeRewriteRule;
702
703impl TypeChangeRewriteRule {
704    fn is_type_change_safe(old_type: &str, new_type: &str, pg_version: u32) -> bool {
705        let old = old_type.to_lowercase();
706        let new = new_type.to_lowercase();
707        if old == new {
708            return true;
709        }
710
711        let old_base = old.split('(').next().unwrap_or(&old).trim();
712        let new_base = new.split('(').next().unwrap_or(&new).trim();
713
714        if (old_base == "varchar" || old_base == "character varying")
715            && (new_base == "varchar" || new_base == "character varying" || new_base == "text")
716        {
717            if new == "text" || new == "varchar" || new == "character varying" {
718                return true;
719            }
720            if let Some(old_mod) = extract_type_modifier_from_type_string(&old)
721                && let Some(new_mod) = extract_type_modifier_from_type_string(&new)
722                && old_mod <= new_mod
723            {
724                return true;
725            }
726        }
727
728        if pg_version >= 120000
729            && (old_base == "numeric" || old_base == "decimal")
730            && (new_base == "numeric" || new_base == "decimal")
731        {
732            // Changing to unconstrained numeric is always safe (widest form).
733            if !new.contains('(') {
734                return true;
735            }
736            // Changing to a constrained numeric(p, s) is safe if the new
737            // precision is >= the old precision and the new scale is >= the
738            // old scale (i.e. the new type can represent every value the old
739            // type could). If the old type is unconstrained we cannot prove
740            // widening, so we fall through to false.
741            if let (Some((old_p, old_s)), Some((new_p, new_s))) =
742                (parse_numeric_params(&old), parse_numeric_params(&new))
743                && new_p >= old_p
744                && new_s >= old_s
745            {
746                return true;
747            }
748        }
749
750        false
751    }
752
753    /// Detects whether a type change narrows a VARCHAR(n) column
754    /// using type_modifier values from the cache.
755    ///
756    /// atttypmod for VARCHAR(n) encodes the character limit:
757    ///   typmod = (limit + VARHDRSZ), where VARHDRSZ is 4
758    ///
759    /// A smaller typmod means a smaller character limit, which is lossy.
760    /// Returns true if the new modifier represents a smaller limit than the old.
761    pub fn is_lossy_varchar_narrowing(
762        old_modifier: Option<i32>,
763        new_modifier: Option<i32>,
764    ) -> bool {
765        match (old_modifier, new_modifier) {
766            // PostgreSQL uses -1 for an unbounded character limit.
767            (Some(-1), Some(new)) if new != -1 => true,
768            // If the new one is unbounded, it's never narrowing
769            (_, Some(-1)) => false,
770            // Bounded values narrow when the new character limit is smaller.
771            (Some(old), Some(new)) => new < old,
772            // A missing modifier cannot prove a bounded old limit.
773            (None, Some(new)) if new != -1 => true,
774            _ => false,
775        }
776    }
777}
778
779/// Parses precision and scale from a numeric/decimal type string.
780/// Returns `Some((precision, scale))` for `numeric(p, s)` or `numeric(p)` (scale=0).
781/// Returns `None` if the type has no parameters.
782fn parse_numeric_params(ty: &str) -> Option<(i32, i32)> {
783    let lower = ty.to_lowercase();
784    let paren_start = lower.find('(')?;
785    let paren_end = lower.find(')')?;
786    let inner = &lower[paren_start + 1..paren_end];
787    let mut parts = inner.splitn(2, ',');
788    let precision: i32 = parts.next()?.trim().parse().ok()?;
789    let scale: i32 = parts
790        .next()
791        .map(|s| s.trim().parse().unwrap_or(0))
792        .unwrap_or(0);
793    Some((precision, scale))
794}
795
796/// Extracts a synthetic type_modifier-like value from a type string.
797/// Used when the new type comes from the migration SQL (not from the cache).
798/// For varchar(N), derives the atttypmod from the character limit.
799pub fn extract_type_modifier_from_type_string(ty: &str) -> Option<i32> {
800    let lower = ty.to_lowercase().trim().to_string();
801    // Check for varchar(N) or character varying(N)
802    if lower.starts_with("varchar(") || lower.starts_with("character varying(") {
803        let paren_start = lower.find('(')?;
804        let paren_end = lower[paren_start..].find(')')?;
805        let num_str = &lower[paren_start + 1..paren_start + paren_end];
806        let limit: i32 = num_str.parse().ok()?;
807        // VARCHAR atttypmod is the character limit plus VARHDRSZ.
808        Some(limit + 4)
809    } else {
810        None
811    }
812}
813
814impl Rule for TypeChangeRewriteRule {
815    fn id(&self) -> &'static str {
816        "type-change-rewrite"
817    }
818    fn default_tier(&self) -> ViolationTier {
819        ViolationTier::Tier1
820    }
821    fn recipe(&self) -> &'static str {
822        "Changing this column type requires an ACCESS EXCLUSIVE table rewrite. Add a new column, backfill, and swap."
823    }
824
825    fn evaluate(
826        &self,
827        mutation: &Mutation,
828        result: &MutationResult,
829        pre_state: &crate::analysis::state::PreState,
830        state: &AnalysisState,
831        config: &Config,
832        _cascade_closure: Option<&CascadeResult>,
833    ) -> Vec<Violation> {
834        if *result == MutationResult::Skipped {
835            return vec![];
836        }
837
838        let mut violations = Vec::new();
839
840        if let Mutation::AlterTable(alter) = mutation
841            && let AlterTableActionMutation::SetType {
842                column,
843                ty,
844                has_using: _,
845            } = &alter.action
846        {
847            let pg_version = state.pg_version_num.unwrap_or(config.assume_pg_version);
848
849            let (is_safe, rows, old_type_str, old_modifier) =
850                match pre_state.relations.get(&alter.id) {
851                    Some(rel) => {
852                        let col_info = rel.columns.iter().find(|c| c.name == *column);
853                        let old_ty = col_info.and_then(|col| col.data_type.as_ref());
854
855                        let safe = old_ty
856                            .map(|o| Self::is_type_change_safe(o, ty, pg_version))
857                            .unwrap_or(false);
858                        (
859                            safe,
860                            rel.estimated_rows.unwrap_or(config.default_rows),
861                            old_ty.cloned().unwrap_or_else(|| "unknown".to_string()),
862                            col_info.and_then(|col| col.type_modifier),
863                        )
864                    }
865                    None => (false, config.default_rows, "unknown".to_string(), None),
866                };
867
868            if !is_safe {
869                let tier1_threshold = config.rule_tier1_threshold(self.id());
870
871                let tier = if rows >= tier1_threshold {
872                    ViolationTier::Tier1
873                } else {
874                    ViolationTier::Tier2
875                };
876
877                let new_modifier = extract_type_modifier_from_type_string(ty);
878
879                if Self::is_lossy_varchar_narrowing(old_modifier, new_modifier) {
880                    violations.push(Violation { source_range: None,
881                        rule_id: self.id(),
882                        operation_kind: OperationKind::AlterColumnType,
883                        object_kind: ObjectKind::Table,
884                        object_name: format!("{}.{}", alter.id, column),
885                        tier,
886                        reason: format!(
887                            "Changing column {}.{} type from {} to {} narrows VARCHAR precision (lossy)",
888                            alter.id, column, old_type_str, ty
889                        ),
890                        recipe: "Narrowing VARCHAR(n) precision may cause data truncation. Consider adding a new column, backfilling, and then dropping the old one.",
891                        dedup_key: None,
892                                    sql: None,
893                                    fk_dependency_related: false,
894                    });
895                } else {
896                    violations.push(Violation {
897                        source_range: None,
898                        rule_id: self.id(),
899                        operation_kind: OperationKind::AlterColumnType,
900                        object_kind: ObjectKind::Table,
901                        object_name: format!("{}.{}", alter.id, column),
902                        tier,
903                        reason: format!(
904                            "Changing column {}.{} type from {} to {} causes a table rewrite",
905                            alter.id, column, old_type_str, ty
906                        ),
907                        recipe: self.recipe(),
908                        dedup_key: None,
909                        sql: None,
910                        fk_dependency_related: false,
911                    });
912                }
913            }
914        }
915        violations
916    }
917}