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