Skip to main content

safe_migrate/rules/
views.rs

1// FILE: src/rules/views.rs
2use 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                // CONCURRENTLY refresh requires at least one unique index
104                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}