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 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 (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 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 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 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
520fn 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 if old == new {
529 return false;
530 }
531
532 let old_base = old.split('(').next().unwrap_or(&old).trim();
534 let new_base = new.split('(').next().unwrap_or(&new).trim();
535
536 if let (Some(old_lim), Some(new_lim)) =
538 (extract_varchar_limit(&old), extract_varchar_limit(&new))
539 {
540 return new_lim < old_lim;
542 }
543
544 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 let old_varchar_limit = extract_varchar_limit(&old);
555 if old_varchar_limit.is_some() {
556 if new == "text" || new == "varchar" || new == "character varying" {
558 return false;
559 }
560 return true;
562 }
563
564 if let (Some(old_sz), Some(new_sz)) = (
567 integer_type_size_bits(old_base),
568 integer_type_size_bits(new_base),
569 ) {
570 return new_sz < old_sz;
572 }
573
574 false
576}
577
578fn 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 None
590 } else {
591 None
592 }
593}
594
595fn 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 if !new.contains('(') {
734 return true;
735 }
736 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 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 (Some(-1), Some(new)) if new != -1 => true,
768 (_, Some(-1)) => false,
770 (Some(old), Some(new)) => new < old,
772 (None, Some(new)) if new != -1 => true,
774 _ => false,
775 }
776 }
777}
778
779fn 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
796pub fn extract_type_modifier_from_type_string(ty: &str) -> Option<i32> {
800 let lower = ty.to_lowercase().trim().to_string();
801 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 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}