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                                    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                // CONCURRENTLY refresh requires at least one unique index
106                let has_unique_index = state
107                    .local
108                    .graph
109                    .indexes
110                    .iter()
111                    .any(|idx| idx.relation_id == refresh.id && idx.is_unique);
112
113                if !has_unique_index {
114                    violations.push(Violation { source_range: None,
115                        rule_id: self.id(),
116                        operation_kind: OperationKind::RefreshMaterializedView,
117                        object_kind: ObjectKind::MaterializedView,
118                        object_name: refresh.id.to_string(),
119                        tier: ViolationTier::Tier1,
120                        reason: format!("REFRESH MATERIALIZED VIEW CONCURRENTLY on {} requires a unique index", refresh.id),
121                        recipe: "Create a unique index on the materialized view before attempting a concurrent refresh.",
122                        dedup_key: None,
123                                    sql: None,
124                                    fk_dependency_related: false,
125                    });
126                }
127            }
128        }
129        violations
130    }
131}