1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct StoragePlan {
17 pub partition: PartitionStrategy,
19 pub compression: CompressionStrategy,
21 pub index: IndexStrategy,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(tag = "strategy", rename_all = "snake_case")]
31pub enum PartitionStrategy {
32 None,
34 RangeByTimeDimension {
36 field: String,
38 granularity: PartitionGranularity,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 retention: Option<RetentionPolicy>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
47 look_ahead: Option<u32>,
48 #[serde(default, skip_serializing_if = "PartitionWindow::is_default")]
64 window: PartitionWindow,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
71pub struct PartitionWindow {
72 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub history_days: Option<u32>,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub forecast_days: Option<u32>,
82
83 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub partition_budget: Option<u32>,
88}
89
90impl PartitionWindow {
91 #[must_use]
94 pub fn is_default(&self) -> bool {
95 self == &Self::default()
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum PartitionGranularity {
107 Day,
109 Week,
111 Month,
113 Quarter,
115 Year,
117}
118
119impl PartitionGranularity {
120 #[must_use]
126 pub const fn next_coarser(self) -> Option<Self> {
127 match self {
128 Self::Day => Some(Self::Week),
129 Self::Week => Some(Self::Month),
130 Self::Month => Some(Self::Quarter),
131 Self::Quarter => Some(Self::Year),
132 Self::Year => None,
133 }
134 }
135
136 #[must_use]
139 pub const fn approx_days(self) -> u32 {
140 match self {
141 Self::Day => 1,
142 Self::Week => 7,
143 Self::Month => 30,
144 Self::Quarter => 92,
145 Self::Year => 365,
146 }
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct RetentionPolicy {
153 pub keep: u32,
156 #[serde(default)]
159 pub action: RetentionAction,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum RetentionAction {
166 #[default]
168 Drop,
169 Archive,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(tag = "kind", rename_all = "snake_case")]
179pub enum CompressionStrategy {
180 None,
182 Page,
184 Row,
186 Columnstore,
188 ColumnstoreArchive,
190 Lz4,
192 Zstd {
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 level: Option<u8>,
197 },
198 Snappy,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(tag = "kind", rename_all = "snake_case")]
208pub enum IndexStrategy {
209 None,
211 ClusteredColumnstore,
213 NonClusteredColumnstore,
215 ZOrder {
217 columns: Vec<String>,
219 },
220 ClusterBy {
222 columns: Vec<String>,
224 },
225}
226
227impl StoragePlan {
228 #[must_use]
230 pub fn none() -> Self {
231 Self {
232 partition: PartitionStrategy::None,
233 compression: CompressionStrategy::None,
234 index: IndexStrategy::None,
235 }
236 }
237
238 #[must_use]
240 pub fn is_partitioned(&self) -> bool {
241 !matches!(self.partition, PartitionStrategy::None)
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct PartitionMaintenance {
251 pub extend_proc_name: String,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub retain_proc_name: Option<String>,
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn storage_plan_none_is_empty() {
265 let plan = StoragePlan::none();
266 assert!(!plan.is_partitioned());
267 assert!(matches!(plan.partition, PartitionStrategy::None));
268 assert!(matches!(plan.compression, CompressionStrategy::None));
269 assert!(matches!(plan.index, IndexStrategy::None));
270 }
271
272 #[test]
273 fn range_partition_round_trips_via_yaml() {
274 let plan = StoragePlan {
275 partition: PartitionStrategy::RangeByTimeDimension {
276 field: "sale_date".into(),
277 granularity: PartitionGranularity::Day,
278 retention: Some(RetentionPolicy { keep: 730, action: RetentionAction::Drop }),
279 look_ahead: Some(7),
280 window: PartitionWindow::default(),
281 },
282 compression: CompressionStrategy::Page,
283 index: IndexStrategy::ClusteredColumnstore,
284 };
285 let yaml = serde_yaml::to_string(&plan).unwrap();
286 let parsed: StoragePlan = serde_yaml::from_str(&yaml).unwrap();
287 assert_eq!(plan, parsed);
288 assert!(plan.is_partitioned());
289 }
290
291 #[test]
292 fn partition_window_default_is_skipped_in_yaml() {
293 let plan = StoragePlan {
294 partition: PartitionStrategy::RangeByTimeDimension {
295 field: "sale_date".into(),
296 granularity: PartitionGranularity::Day,
297 retention: None,
298 look_ahead: None,
299 window: PartitionWindow::default(),
300 },
301 compression: CompressionStrategy::None,
302 index: IndexStrategy::None,
303 };
304 let yaml = serde_yaml::to_string(&plan).unwrap();
305 assert!(!yaml.contains("window"), "default window should not serialize: {yaml}");
306 }
307
308 #[test]
309 fn partition_window_round_trips_via_yaml() {
310 let window = PartitionWindow {
311 history_days: Some(1825),
312 forecast_days: Some(365),
313 partition_budget: Some(2000),
314 };
315 let plan = StoragePlan {
316 partition: PartitionStrategy::RangeByTimeDimension {
317 field: "sale_date".into(),
318 granularity: PartitionGranularity::Day,
319 retention: None,
320 look_ahead: None,
321 window: window.clone(),
322 },
323 compression: CompressionStrategy::None,
324 index: IndexStrategy::None,
325 };
326 let yaml = serde_yaml::to_string(&plan).unwrap();
327 let parsed: StoragePlan = serde_yaml::from_str(&yaml).unwrap();
328 assert_eq!(plan, parsed);
329 let PartitionStrategy::RangeByTimeDimension { window: w, .. } = parsed.partition else {
330 unreachable!()
331 };
332 assert_eq!(w, window);
333 }
334
335 #[test]
336 fn next_coarser_walks_to_year_then_stops() {
337 assert_eq!(PartitionGranularity::Day.next_coarser(), Some(PartitionGranularity::Week));
338 assert_eq!(PartitionGranularity::Week.next_coarser(), Some(PartitionGranularity::Month));
339 assert_eq!(PartitionGranularity::Month.next_coarser(), Some(PartitionGranularity::Quarter));
340 assert_eq!(PartitionGranularity::Quarter.next_coarser(), Some(PartitionGranularity::Year));
341 assert_eq!(PartitionGranularity::Year.next_coarser(), None);
342 }
343
344 #[test]
345 fn zstd_with_level_serializes() {
346 let c = CompressionStrategy::Zstd { level: Some(3) };
347 let json = serde_json::to_string(&c).unwrap();
348 assert!(json.contains("\"zstd\""));
349 assert!(json.contains('3'));
350 }
351
352 #[test]
353 fn retention_action_defaults_to_drop() {
354 let yaml = "keep: 90\n";
355 let policy: RetentionPolicy = serde_yaml::from_str(yaml).unwrap();
356 assert_eq!(policy.keep, 90);
357 assert_eq!(policy.action, RetentionAction::Drop);
358 }
359
360 #[test]
361 fn z_order_round_trips_via_json() {
362 let idx = IndexStrategy::ZOrder { columns: vec!["sale_date".into(), "store_id".into()] };
363 let json = serde_json::to_string(&idx).unwrap();
364 let parsed: IndexStrategy = serde_json::from_str(&json).unwrap();
365 assert_eq!(idx, parsed);
366 }
367
368 #[test]
369 fn cluster_by_round_trips_via_json() {
370 let idx = IndexStrategy::ClusterBy { columns: vec!["created_at".into()] };
371 let json = serde_json::to_string(&idx).unwrap();
372 let parsed: IndexStrategy = serde_json::from_str(&json).unwrap();
373 assert_eq!(idx, parsed);
374 }
375
376 #[test]
377 fn partition_strategy_none_serializes_with_tag() {
378 let p = PartitionStrategy::None;
379 let json = serde_json::to_string(&p).unwrap();
380 assert!(json.contains("\"none\""));
381 }
382
383 #[test]
384 fn partition_maintenance_round_trips() {
385 let m = PartitionMaintenance {
386 extend_proc_name: "core_sales_partition_extend".into(),
387 retain_proc_name: Some("core_sales_partition_retain".into()),
388 };
389 let json = serde_json::to_string(&m).unwrap();
390 let parsed: PartitionMaintenance = serde_json::from_str(&json).unwrap();
391 assert_eq!(m, parsed);
392
393 let no_retain = PartitionMaintenance {
394 extend_proc_name: "core_orders_partition_extend".into(),
395 retain_proc_name: None,
396 };
397 let json = serde_json::to_string(&no_retain).unwrap();
398 assert!(!json.contains("retain_proc_name"));
399 }
400}