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 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 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"
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 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(_)) {
441 return violations; }
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 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
521fn 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 if old == new {
530 return false;
531 }
532
533 let old_base = old.split('(').next().unwrap_or(&old).trim();
535 let new_base = new.split('(').next().unwrap_or(&new).trim();
536
537 if let (Some(old_lim), Some(new_lim)) =
539 (extract_varchar_limit(&old), extract_varchar_limit(&new))
540 {
541 return new_lim < old_lim;
543 }
544
545 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 let old_varchar_limit = extract_varchar_limit(&old);
556 if old_varchar_limit.is_some() {
557 if new == "text" || new == "varchar" || new == "character varying" {
559 return false;
560 }
561 return true;
563 }
564
565 if let (Some(old_sz), Some(new_sz)) = (
568 integer_type_size_bits(old_base),
569 integer_type_size_bits(new_base),
570 ) {
571 return new_sz < old_sz;
573 }
574
575 false
577}
578
579fn 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 None
591 } else {
592 None
593 }
594}
595
596fn 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 if !new.contains('(') {
735 return true;
736 }
737 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 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 (Some(-1), Some(new)) if new != -1 => true,
769 (_, Some(-1)) => false,
771 (Some(old), Some(new)) => new < old,
773 (None, Some(new)) if new != -1 => true,
775 _ => false,
776 }
777 }
778}
779
780fn 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
797pub fn extract_type_modifier_from_type_string(ty: &str) -> Option<i32> {
801 let lower = ty.to_lowercase().trim().to_string();
802 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 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}