safe_migrate/rules/
views.rs1use crate::analysis::mutations::Mutation;
2use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
3use crate::engine::config::Config;
4use crate::model::relation::Persistence;
5use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
6use crate::rules::Rule;
7
8pub struct MaterializedViewRefreshRule;
9
10impl Rule for MaterializedViewRefreshRule {
11 fn id(&self) -> &'static str {
12 "blocking-mat-view-refresh"
13 }
14 fn default_tier(&self) -> ViolationTier {
15 ViolationTier::Tier1
16 }
17 fn recipe(&self) -> &'static str {
18 "Refreshing a materialized view without CONCURRENTLY prevents reading from it during the refresh."
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: 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::RefreshMaterializedView(refresh) = mutation {
37 if !refresh.concurrently {
38 let (is_temp, is_stale, rows) = match pre_state.relations.get(&refresh.id) {
39 Some(rel) => {
40 let stale =
41 rel.is_stale() && state.baseline_relations.contains(&refresh.id);
42 (
43 rel.persistence == Persistence::Temporary,
44 stale,
45 rel.estimated_rows.unwrap_or(config.default_rows),
46 )
47 }
48 None => (false, true, config.default_rows),
49 };
50
51 if is_temp {
52 return violations;
53 }
54
55 if is_stale {
56 let key = format!("{}_stale_{}", self.id(), refresh.id);
57 violations.push(Violation { source_range: None,
58 rule_id: self.id(),
59 operation_kind: OperationKind::RefreshMaterializedView,
60 object_kind: ObjectKind::MaterializedView,
61 object_name: refresh.id.to_string(),
62 tier: ViolationTier::Tier2,
63 reason: format!("Materialized view {} statistics are stale. Lock evaluations may be inaccurate.", refresh.id),
64 recipe: "Run ANALYZE to ensure accurate row estimates.",
65 dedup_key: Some(key),
66 sql: None,
67 fk_dependency_related: false,
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 fk_dependency_related: false,
101 });
102 }
103 } else {
104 let has_unique_index = state.local.graph.edges.iter().any(|e| {
106 if let crate::analysis::graph::DependencyKind::IndexOnRelation {
107 is_unique,
108 ..
109 } = &e.kind
110 {
111 e.referenced == refresh.id && *is_unique
112 } else {
113 false
114 }
115 });
116
117 if !has_unique_index {
118 violations.push(Violation { source_range: None,
119 rule_id: self.id(),
120 operation_kind: OperationKind::RefreshMaterializedView,
121 object_kind: ObjectKind::MaterializedView,
122 object_name: refresh.id.to_string(),
123 tier: ViolationTier::Tier1,
124 reason: format!("REFRESH MATERIALIZED VIEW CONCURRENTLY on {} requires a unique index", refresh.id),
125 recipe: "Create a unique index on the materialized view before attempting a concurrent refresh.",
126 dedup_key: None,
127 sql: None,
128 fk_dependency_related: false,
129 });
130 }
131 }
132 }
133 violations
134 }
135}