1use std::collections::HashMap;
10
11use crate::error::Result;
12
13use super::StateStore;
14
15pub struct ShapeWarning {
17 pub column: String,
18 pub stored_max_bytes: u64,
19 pub current_max_bytes: u64,
20 pub growth_factor: f64,
22}
23
24impl StateStore {
25 pub fn get_shape_stats(&self, export_name: &str) -> Result<HashMap<String, u64>> {
27 Ok(self
28 .query(
29 "SELECT column_name, max_byte_len FROM export_shape WHERE export_name = ?1",
30 &[export_name.into()],
31 |r| (r.text(0), r.i64(1) as u64),
32 )?
33 .into_iter()
34 .collect())
35 }
36
37 pub fn store_shape_stats(&self, export_name: &str, stats: &HashMap<String, u64>) -> Result<()> {
39 let now = chrono::Utc::now().to_rfc3339();
40 let sql = "INSERT INTO export_shape (export_name, column_name, max_byte_len, updated_at)
41 VALUES (?1, ?2, ?3, ?4)
42 ON CONFLICT(export_name, column_name) DO UPDATE SET
43 max_byte_len = MAX(max_byte_len, excluded.max_byte_len),
44 updated_at = excluded.updated_at";
45 for (col, &max_bytes) in stats {
46 self.execute(
47 sql,
48 &[
49 export_name.into(),
50 col.as_str().into(),
51 (max_bytes as i64).into(),
52 now.as_str().into(),
53 ],
54 )?;
55 }
56 Ok(())
57 }
58
59 pub fn detect_shape_drift(
67 &self,
68 export_name: &str,
69 current: &HashMap<String, u64>,
70 warn_factor: f64,
71 ) -> Result<Vec<ShapeWarning>> {
72 let stored = self.get_shape_stats(export_name)?;
73 let mut warnings = Vec::new();
74
75 for (col, ¤t_max) in current {
76 if let Some(&stored_max) = stored.get(col)
77 && stored_max > 0
78 && (current_max as f64) > stored_max as f64 * warn_factor
79 {
80 warnings.push(ShapeWarning {
81 column: col.clone(),
82 stored_max_bytes: stored_max,
83 current_max_bytes: current_max,
84 growth_factor: current_max as f64 / stored_max as f64,
85 });
86 }
87 }
88
89 self.store_shape_stats(export_name, current)?;
90 Ok(warnings)
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 fn store() -> StateStore {
99 StateStore::open_in_memory().expect("in-memory store")
100 }
101
102 #[test]
103 fn first_run_no_warnings() {
104 let s = store();
105 let stats: HashMap<String, u64> =
106 [("notes".into(), 512u64), ("description".into(), 1024u64)].into();
107 let warnings = s.detect_shape_drift("orders", &stats, 2.0).unwrap();
108 assert!(warnings.is_empty(), "first run must not warn");
109 }
110
111 #[test]
112 fn growth_below_threshold_no_warning() {
113 let s = store();
114 let v1: HashMap<String, u64> = [("body".into(), 1000u64)].into();
115 s.detect_shape_drift("t", &v1, 2.0).unwrap();
116
117 let v2: HashMap<String, u64> = [("body".into(), 1800u64)].into();
118 let warnings = s.detect_shape_drift("t", &v2, 2.0).unwrap();
119 assert!(warnings.is_empty());
120 }
121
122 #[test]
123 fn growth_above_threshold_warns() {
124 let s = store();
125 let v1: HashMap<String, u64> = [("body".into(), 1000u64)].into();
126 s.detect_shape_drift("t", &v1, 2.0).unwrap();
127
128 let v2: HashMap<String, u64> = [("body".into(), 2500u64)].into();
129 let warnings = s.detect_shape_drift("t", &v2, 2.0).unwrap();
130 assert_eq!(warnings.len(), 1);
131 assert_eq!(warnings[0].column, "body");
132 assert_eq!(warnings[0].stored_max_bytes, 1000);
133 assert_eq!(warnings[0].current_max_bytes, 2500);
134 assert!((warnings[0].growth_factor - 2.5).abs() < 0.01);
135 }
136
137 #[test]
138 fn high_water_mark_advances_after_warning() {
139 let s = store();
140 let v1: HashMap<String, u64> = [("text".into(), 100u64)].into();
141 s.detect_shape_drift("t", &v1, 2.0).unwrap();
142
143 let v2: HashMap<String, u64> = [("text".into(), 300u64)].into();
144 s.detect_shape_drift("t", &v2, 2.0).unwrap();
145
146 let v3: HashMap<String, u64> = [("text".into(), 450u64)].into();
147 let warnings = s.detect_shape_drift("t", &v3, 2.0).unwrap();
148 assert!(
149 warnings.is_empty(),
150 "must not re-warn after high-water mark advanced"
151 );
152 }
153
154 #[test]
155 fn new_column_in_later_run_no_warning() {
156 let s = store();
157 let v1: HashMap<String, u64> = [("id_str".into(), 36u64)].into();
158 s.detect_shape_drift("t", &v1, 2.0).unwrap();
159
160 let v2: HashMap<String, u64> =
161 [("id_str".into(), 36u64), ("new_col".into(), 9999u64)].into();
162 let warnings = s.detect_shape_drift("t", &v2, 2.0).unwrap();
163 assert!(
164 warnings.is_empty(),
165 "new columns with no history must not warn"
166 );
167 }
168}