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