safe_migrate/rules/
views.rs1use crate::analysis::mutations::Mutation;
3use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
4use crate::engine::config::Config;
5use crate::model::relation::Persistence;
6use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
7use crate::rules::Rule;
8
9pub struct MaterializedViewRefreshRule;
10
11impl Rule for MaterializedViewRefreshRule {
12 fn id(&self) -> &'static str {
13 "blocking-mat-view-refresh"
14 }
15 fn default_tier(&self) -> ViolationTier {
16 ViolationTier::Tier1
17 }
18 fn recipe(&self) -> &'static str {
19 "Refreshing a materialized view without CONCURRENTLY prevents reading from it during the refresh."
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: 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::RefreshMaterializedView(refresh) = mutation {
38 if !refresh.concurrently {
39 let (is_temp, is_stale, rows) = match pre_state.relations.get(&refresh.id) {
40 Some(rel) => {
41 let stale =
42 rel.is_stale() && state.baseline_relations.contains(&refresh.id);
43 (
44 rel.persistence == Persistence::Temporary,
45 stale,
46 rel.estimated_rows.unwrap_or(config.default_rows),
47 )
48 }
49 None => (false, true, config.default_rows),
50 };
51
52 if is_temp {
53 return violations;
54 }
55
56 if is_stale {
57 let key = format!("{}_stale_{}", self.id(), refresh.id);
58 violations.push(Violation { source_range: None,
59 rule_id: self.id(),
60 operation_kind: OperationKind::RefreshMaterializedView,
61 object_kind: ObjectKind::MaterializedView,
62 object_name: refresh.id.to_string(),
63 tier: ViolationTier::Tier2,
64 reason: format!("Materialized view {} statistics are stale. Lock evaluations may be inaccurate.", refresh.id),
65 recipe: "Run ANALYZE to ensure accurate row estimates.",
66 dedup_key: Some(key),
67 sql: None,
68 });
69 }
70
71 let tier1_threshold = config.rule_tier1_threshold(self.id());
72 let tier2_threshold = config.rule_tier2_threshold(self.id());
73
74 let tier = if rows >= tier1_threshold {
75 ViolationTier::Tier1
76 } else if rows >= tier2_threshold {
77 ViolationTier::Tier2
78 } else {
79 ViolationTier::Tier3
80 };
81
82 if tier != ViolationTier::Tier3 {
83 let mut reason =
84 format!("Blocking materialized view refresh on {}", refresh.id);
85 if is_stale {
86 reason.push_str(" [WARNING: Based on offline/stale statistics]");
87 }
88
89 violations.push(Violation {
90 source_range: None,
91 rule_id: self.id(),
92 operation_kind: OperationKind::RefreshMaterializedView,
93 object_kind: ObjectKind::MaterializedView,
94 object_name: refresh.id.to_string(),
95 tier,
96 reason,
97 recipe: self.recipe(),
98 dedup_key: None,
99 sql: None,
100 });
101 }
102 } else {
103 let has_unique_index = state
105 .local
106 .graph
107 .indexes
108 .iter()
109 .any(|idx| idx.relation_id == refresh.id && idx.is_unique);
110
111 if !has_unique_index {
112 violations.push(Violation { source_range: None,
113 rule_id: self.id(),
114 operation_kind: OperationKind::RefreshMaterializedView,
115 object_kind: ObjectKind::MaterializedView,
116 object_name: refresh.id.to_string(),
117 tier: ViolationTier::Tier1,
118 reason: format!("REFRESH MATERIALIZED VIEW CONCURRENTLY on {} requires a unique index", refresh.id),
119 recipe: "Create a unique index on the materialized view before attempting a concurrent refresh.",
120 dedup_key: None,
121 sql: None,
122 });
123 }
124 }
125 }
126 violations
127 }
128}