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
25pub 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
101pub 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 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}