Skip to main content

safe_migrate/rules/
views.rs

1use 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                // CONCURRENTLY refresh requires at least one unique index
105                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}