Skip to main content

safe_migrate/rules/
registry.rs

1use crate::report::violations::ViolationTier;
2use crate::rules::Rule;
3use crate::rules::conflict::ConflictRule;
4use crate::rules::constraints::BlockingConstraintRule;
5use crate::rules::destructive::{
6    CascadingDropRule, CreateTableAsSelectRule, DropDatabaseRule, DropSchemaCascadeRule,
7    GeneralCascadeRule, ReversibilityRule, SizeAwareAddColumnRule, TypeChangeRewriteRule,
8};
9use crate::rules::drift::DriftDetectionRule;
10use crate::rules::expressions::VolatileDefaultRule;
11use crate::rules::functions::{BrokenComputeRule, FunctionVolatilityRule};
12use crate::rules::idempotency::IdempotencyRule;
13use crate::rules::indexes::ConcurrentIndexRule;
14use crate::rules::opaque::OpaqueDynamicSqlRule;
15use crate::rules::partitions::{PartitionLockRule, PartitionStrategyMismatchRule};
16use crate::rules::policies::RestrictivePolicyRule;
17use crate::rules::security::OverbroadGrantRule;
18use crate::rules::timeouts::{RequireLockTimeoutRule, RequireStatementTimeoutRule};
19use crate::rules::transactions::{
20    AlterTypeAddValueRule, ConcurrentInsideTransactionRule, VacuumFullRule,
21};
22use crate::rules::triggers::DisableTriggerRule;
23use crate::rules::views::MaterializedViewRefreshRule;
24
25/// Stable user-facing metadata and construction for one primary rule.
26///
27/// Keep this registry in evaluation order. Discovery, configuration validation,
28/// documentation checks, and engine construction all read it. Auxiliary
29/// findings emitted by a primary rule are not entries.
30pub struct RuleDescriptor {
31    pub id: &'static str,
32    pub title: &'static str,
33    pub summary: &'static str,
34    pub impact: &'static str,
35    pub supported_configuration_fields: &'static [RuleConfigurationField],
36    factory: fn() -> Box<dyn Rule>,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum RuleConfigurationField {
41    Disabled,
42    Tier1ThresholdRows,
43    Tier2ThresholdRows,
44}
45
46impl RuleConfigurationField {
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::Disabled => "disabled",
50            Self::Tier1ThresholdRows => "tier1_threshold_rows",
51            Self::Tier2ThresholdRows => "tier2_threshold_rows",
52        }
53    }
54}
55
56const DISABLED_ONLY: &[RuleConfigurationField] = &[RuleConfigurationField::Disabled];
57const WITH_TIER1_THRESHOLD: &[RuleConfigurationField] = &[
58    RuleConfigurationField::Disabled,
59    RuleConfigurationField::Tier1ThresholdRows,
60];
61const WITH_ROW_THRESHOLDS: &[RuleConfigurationField] = &[
62    RuleConfigurationField::Disabled,
63    RuleConfigurationField::Tier1ThresholdRows,
64    RuleConfigurationField::Tier2ThresholdRows,
65];
66
67impl RuleDescriptor {
68    pub fn build(&self) -> Box<dyn Rule> {
69        (self.factory)()
70    }
71
72    pub fn default_tier(&self) -> ViolationTier {
73        self.build().default_tier()
74    }
75
76    pub fn recipe(&self) -> &'static str {
77        self.build().recipe()
78    }
79
80    pub fn supports(&self, field: RuleConfigurationField) -> bool {
81        self.supported_configuration_fields.contains(&field)
82    }
83}
84
85macro_rules! descriptor {
86    ($id:literal, $title:literal, $summary:literal, $impact:literal, $rule:expr) => {
87        descriptor!($id, $title, $summary, $impact, $rule, DISABLED_ONLY)
88    };
89    ($id:literal, $title:literal, $summary:literal, $impact:literal, $rule:expr, $fields:expr) => {
90        RuleDescriptor {
91            id: $id,
92            title: $title,
93            summary: $summary,
94            impact: $impact,
95            supported_configuration_fields: $fields,
96            factory: || Box::new($rule),
97        }
98    };
99}
100
101// Marker rules are currently zero-sized; their constructors are kept in this
102// registry so future initialized rules can supply a dedicated factory.
103pub static PRIMARY_RULES: &[RuleDescriptor] = &[
104    descriptor!(
105        "irreversible-migration",
106        "Irreversible migration",
107        "Flags destructive operations that cannot be reversed.",
108        "data loss",
109        ReversibilityRule,
110        WITH_TIER1_THRESHOLD
111    ),
112    descriptor!(
113        "drop-database",
114        "Drop database",
115        "Flags database deletion.",
116        "data loss",
117        DropDatabaseRule
118    ),
119    descriptor!(
120        "drop-schema-cascade",
121        "Drop schema with cascade",
122        "Flags schema-wide cascading deletion.",
123        "data loss",
124        DropSchemaCascadeRule
125    ),
126    descriptor!(
127        "destructive-general-cascade",
128        "Destructive cascade",
129        "Flags cascading non-table drops.",
130        "data loss",
131        GeneralCascadeRule
132    ),
133    descriptor!(
134        "destructive-cascade",
135        "Drop table with cascade",
136        "Flags table drops that remove dependencies.",
137        "data loss",
138        CascadingDropRule
139    ),
140    descriptor!(
141        "create-table-as-select",
142        "Create table as select",
143        "Flags potentially expensive CTAS operations.",
144        "rewrite",
145        CreateTableAsSelectRule
146    ),
147    descriptor!(
148        "size-aware-add-column",
149        "Add column on a large table",
150        "Flags column additions that can rewrite large tables.",
151        "rewrite",
152        SizeAwareAddColumnRule,
153        WITH_TIER1_THRESHOLD
154    ),
155    descriptor!(
156        "type-change-rewrite",
157        "Type change rewrite",
158        "Flags column type changes that rewrite data.",
159        "rewrite",
160        TypeChangeRewriteRule,
161        WITH_TIER1_THRESHOLD
162    ),
163    descriptor!(
164        "blocking-constraint",
165        "Blocking constraint",
166        "Flags constraint changes that lock or scan tables.",
167        "locking",
168        BlockingConstraintRule,
169        WITH_ROW_THRESHOLDS
170    ),
171    descriptor!(
172        "require-concurrent-index",
173        "Require concurrent index",
174        "Flags index changes that should use CONCURRENTLY.",
175        "locking",
176        ConcurrentIndexRule,
177        WITH_ROW_THRESHOLDS
178    ),
179    descriptor!(
180        "require-lock-timeout",
181        "Require lock timeout",
182        "Flags potentially slow statements without an effective lock timeout.",
183        "locking",
184        RequireLockTimeoutRule
185    ),
186    descriptor!(
187        "require-statement-timeout",
188        "Require statement timeout",
189        "Flags potentially slow statements without an effective statement timeout.",
190        "operability",
191        RequireStatementTimeoutRule
192    ),
193    descriptor!(
194        "blocking-mat-view-refresh",
195        "Blocking materialized-view refresh",
196        "Flags refreshes that block readers.",
197        "locking",
198        MaterializedViewRefreshRule,
199        WITH_ROW_THRESHOLDS
200    ),
201    descriptor!(
202        "blocking-partition-mutation",
203        "Blocking partition mutation",
204        "Flags partition attach and detach locks.",
205        "locking",
206        PartitionLockRule,
207        WITH_ROW_THRESHOLDS
208    ),
209    descriptor!(
210        "partition-strategy-mismatch",
211        "Partition strategy mismatch",
212        "Flags incompatible partition attachment.",
213        "correctness",
214        PartitionStrategyMismatchRule
215    ),
216    descriptor!(
217        "restrictive-policy",
218        "Restrictive policy",
219        "Flags policies that narrow row visibility.",
220        "access control",
221        RestrictivePolicyRule
222    ),
223    descriptor!(
224        "disable-trigger",
225        "Disable trigger",
226        "Flags disabled triggers.",
227        "correctness",
228        DisableTriggerRule
229    ),
230    descriptor!(
231        "broken-compute",
232        "Broken compute dependency",
233        "Flags function drops blocked by trigger dependencies.",
234        "correctness",
235        BrokenComputeRule
236    ),
237    descriptor!(
238        "function-volatility-change",
239        "Function volatility change",
240        "Flags changed function volatility.",
241        "query planning",
242        FunctionVolatilityRule
243    ),
244    descriptor!(
245        "missing-idempotency",
246        "Missing idempotency",
247        "Flags migrations unsafe to rerun.",
248        "operability",
249        IdempotencyRule
250    ),
251    descriptor!(
252        "concurrent-in-transaction",
253        "Concurrent index in transaction",
254        "Flags CONCURRENTLY inside a transaction.",
255        "correctness",
256        ConcurrentInsideTransactionRule
257    ),
258    descriptor!(
259        "alter-type-add-value-txn",
260        "Enum value in transaction",
261        "Flags enum additions whose new value is unavailable until commit.",
262        "correctness",
263        AlterTypeAddValueRule
264    ),
265    descriptor!(
266        "vacuum-full",
267        "Vacuum full",
268        "Flags VACUUM FULL in migrations.",
269        "locking",
270        VacuumFullRule
271    ),
272    descriptor!(
273        "opaque-dynamic-sql",
274        "Opaque dynamic SQL",
275        "Flags SQL whose schema effects cannot be modeled.",
276        "confidence",
277        OpaqueDynamicSqlRule
278    ),
279    descriptor!(
280        "volatile-default",
281        "Volatile default",
282        "Flags volatile default expressions.",
283        "correctness",
284        VolatileDefaultRule
285    ),
286    descriptor!(
287        "overbroad-grant",
288        "Overbroad grant",
289        "Flags broad public privileges.",
290        "access control",
291        OverbroadGrantRule
292    ),
293    descriptor!(
294        "schema-drift",
295        "Schema drift",
296        "Flags references missing from the baseline.",
297        "correctness",
298        DriftDetectionRule
299    ),
300    descriptor!(
301        "chain-conflict",
302        "Migration chain conflict",
303        "Flags statements that conflict with prior migration state.",
304        "correctness",
305        ConflictRule
306    ),
307];
308
309pub fn primary_rule_ids() -> impl Iterator<Item = &'static str> {
310    PRIMARY_RULES.iter().map(|rule| rule.id)
311}
312
313pub fn find_primary_rule(id: &str) -> Option<&'static RuleDescriptor> {
314    PRIMARY_RULES.iter().find(|rule| rule.id == id)
315}
316
317pub fn validate_rule_configuration(config: &crate::engine::config::Config) -> Result<(), String> {
318    if config.tier1_threshold_rows < config.tier2_threshold_rows {
319        return Err(format!(
320            "tier1_threshold_rows ({}) must be greater than or equal to tier2_threshold_rows ({})",
321            config.tier1_threshold_rows, config.tier2_threshold_rows
322        ));
323    }
324
325    let mut rule_ids: Vec<_> = config.rules.keys().map(String::as_str).collect();
326    rule_ids.sort_unstable();
327    for rule_id in rule_ids {
328        let Some(descriptor) = find_primary_rule(rule_id) else {
329            // Config::validate_rule_ids reports unknown IDs with the full list.
330            continue;
331        };
332        let rule = &config.rules[rule_id];
333        if rule.tier1_threshold_rows.is_some()
334            && !descriptor.supports(RuleConfigurationField::Tier1ThresholdRows)
335        {
336            return Err(format!(
337                "Rule '{rule_id}' does not support 'tier1_threshold_rows'"
338            ));
339        }
340        if rule.tier2_threshold_rows.is_some()
341            && !descriptor.supports(RuleConfigurationField::Tier2ThresholdRows)
342        {
343            return Err(format!(
344                "Rule '{rule_id}' does not support 'tier2_threshold_rows'"
345            ));
346        }
347        if descriptor.supports(RuleConfigurationField::Tier1ThresholdRows)
348            && descriptor.supports(RuleConfigurationField::Tier2ThresholdRows)
349        {
350            let tier1 = config.rule_tier1_threshold(rule_id);
351            let tier2 = config.rule_tier2_threshold(rule_id);
352            if tier1 < tier2 {
353                return Err(format!(
354                    "Rule '{rule_id}' has tier1_threshold_rows ({tier1}) below tier2_threshold_rows ({tier2})"
355                ));
356            }
357        }
358    }
359    Ok(())
360}
361
362pub fn build_primary_rules() -> Vec<Box<dyn Rule>> {
363    PRIMARY_RULES.iter().map(RuleDescriptor::build).collect()
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use std::collections::HashSet;
370
371    #[test]
372    fn descriptors_have_unique_ids_matching_the_rules_they_construct() {
373        let ids: HashSet<_> = PRIMARY_RULES
374            .iter()
375            .map(|descriptor| descriptor.id)
376            .collect();
377        assert_eq!(ids.len(), PRIMARY_RULES.len());
378        for descriptor in PRIMARY_RULES {
379            let rule = descriptor.build();
380            assert_eq!(rule.id(), descriptor.id);
381            assert_eq!(descriptor.default_tier(), rule.default_tier());
382            assert_eq!(descriptor.recipe(), rule.recipe());
383        }
384    }
385
386    #[test]
387    fn descriptors_advertise_only_configuration_the_rules_consume() {
388        let tier1_only: HashSet<_> = [
389            "irreversible-migration",
390            "size-aware-add-column",
391            "type-change-rewrite",
392        ]
393        .into_iter()
394        .collect();
395        let both_thresholds: HashSet<_> = [
396            "blocking-constraint",
397            "require-concurrent-index",
398            "blocking-mat-view-refresh",
399            "blocking-partition-mutation",
400        ]
401        .into_iter()
402        .collect();
403
404        for descriptor in PRIMARY_RULES {
405            assert!(descriptor.supports(RuleConfigurationField::Disabled));
406            assert_eq!(
407                descriptor.supports(RuleConfigurationField::Tier1ThresholdRows),
408                tier1_only.contains(descriptor.id) || both_thresholds.contains(descriptor.id),
409                "unexpected Tier 1 threshold metadata for {}",
410                descriptor.id
411            );
412            assert_eq!(
413                descriptor.supports(RuleConfigurationField::Tier2ThresholdRows),
414                both_thresholds.contains(descriptor.id),
415                "unexpected Tier 2 threshold metadata for {}",
416                descriptor.id
417            );
418        }
419    }
420
421    #[test]
422    fn threshold_validation_requires_tier1_at_or_above_tier2() {
423        let globally_reversed = crate::engine::config::Config {
424            tier1_threshold_rows: 9,
425            tier2_threshold_rows: 10,
426            ..crate::engine::config::Config::default()
427        };
428        assert!(
429            validate_rule_configuration(&globally_reversed)
430                .unwrap_err()
431                .contains("tier1_threshold_rows (9)")
432        );
433
434        let mut per_rule_reversed = crate::engine::config::Config::default();
435        per_rule_reversed.rules.insert(
436            "blocking-constraint".into(),
437            crate::engine::config::RuleConfig {
438                tier1_threshold_rows: Some(5),
439                tier2_threshold_rows: Some(6),
440                ..crate::engine::config::RuleConfig::default()
441            },
442        );
443        assert!(
444            validate_rule_configuration(&per_rule_reversed)
445                .unwrap_err()
446                .contains("Rule 'blocking-constraint'")
447        );
448    }
449
450    #[test]
451    fn unsupported_per_rule_thresholds_are_rejected() {
452        let mut config = crate::engine::config::Config::default();
453        config.rules.insert(
454            "require-lock-timeout".to_string(),
455            crate::engine::config::RuleConfig {
456                tier1_threshold_rows: Some(1),
457                ..crate::engine::config::RuleConfig::default()
458            },
459        );
460
461        assert_eq!(
462            validate_rule_configuration(&config).unwrap_err(),
463            "Rule 'require-lock-timeout' does not support 'tier1_threshold_rows'"
464        );
465    }
466}