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 fk_dependency_related: false,
69 });
70 }
71
72 let tier1_threshold = config.rule_tier1_threshold(self.id());
73 let tier2_threshold = config.rule_tier2_threshold(self.id());
74
75 let tier = if rows >= tier1_threshold {
76 ViolationTier::Tier1
77 } else if rows >= tier2_threshold {
78 ViolationTier::Tier2
79 } else {
80 ViolationTier::Tier3
81 };
82
83 if tier != ViolationTier::Tier3 {
84 let mut reason =
85 format!("Blocking materialized view refresh on {}", refresh.id);
86 if is_stale {
87 reason.push_str(" [WARNING: Based on offline/stale statistics]");
88 }
89
90 violations.push(Violation {
91 source_range: None,
92 rule_id: self.id(),
93 operation_kind: OperationKind::RefreshMaterializedView,
94 object_kind: ObjectKind::MaterializedView,
95 object_name: refresh.id.to_string(),
96 tier,
97 reason,
98 recipe: self.recipe(),
99 dedup_key: None,
100 sql: None,
101 fk_dependency_related: false,
102 });
103 }
104 } else {
105 let has_unique_index = state.local.graph.edges.iter().any(|e| {
107 if let crate::analysis::graph::DependencyKind::IndexOnRelation {
108 is_unique,
109 ..
110 } = &e.kind
111 {
112 e.referenced == refresh.id && *is_unique
113 } else {
114 false
115 }
116 });
117
118 if !has_unique_index {
119 violations.push(Violation { source_range: None,
120 rule_id: self.id(),
121 operation_kind: OperationKind::RefreshMaterializedView,
122 object_kind: ObjectKind::MaterializedView,
123 object_name: refresh.id.to_string(),
124 tier: ViolationTier::Tier1,
125 reason: format!("REFRESH MATERIALIZED VIEW CONCURRENTLY on {} requires a unique index", refresh.id),
126 recipe: "Create a unique index on the materialized view before attempting a concurrent refresh.",
127 dedup_key: None,
128 sql: None,
129 fk_dependency_related: false,
130 });
131 }
132 }
133 }
134 violations
135 }
136}