1use crate::error::Result;
2
3use super::StateStore;
4
5#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub struct SchemaColumn {
8 pub name: String,
9 #[serde(rename = "type")]
10 pub data_type: String,
11}
12
13pub fn schema_fingerprint(columns: &[SchemaColumn]) -> String {
29 use xxhash_rust::xxh3::Xxh3;
30
31 let mut sorted: Vec<&SchemaColumn> = columns.iter().collect();
32 sorted.sort_by(|a, b| a.name.cmp(&b.name));
33
34 let mut h = Xxh3::new();
35 for c in &sorted {
36 h.update(c.name.as_bytes());
37 h.update(b"\t");
38 h.update(c.data_type.as_bytes());
39 h.update(b"\n");
40 }
41 format!("xxh3:{:016x}", h.digest())
42}
43
44pub fn arrow_schema_to_columns(schema: &arrow::datatypes::Schema) -> Vec<SchemaColumn> {
62 schema
63 .fields()
64 .iter()
65 .map(|f| SchemaColumn {
66 name: f.name().clone(),
67 data_type: format!("{:?}", f.data_type()),
68 })
69 .collect()
70}
71
72#[derive(Debug)]
74pub struct SchemaChange {
75 pub added: Vec<String>,
76 pub removed: Vec<String>,
77 pub type_changed: Vec<(String, String, String)>, }
79
80impl SchemaChange {
81 pub fn is_empty(&self) -> bool {
82 self.added.is_empty() && self.removed.is_empty() && self.type_changed.is_empty()
83 }
84}
85
86impl StateStore {
91 pub fn get_stored_schema(&self, export_name: &str) -> Result<Option<Vec<SchemaColumn>>> {
92 let json = self.query_opt(
93 "SELECT columns_json FROM export_schema WHERE export_name = ?1",
94 &[export_name.into()],
95 |r| r.text(0),
96 )?;
97 Ok(match json {
98 Some(s) => Some(serde_json::from_str(&s)?),
99 None => None,
100 })
101 }
102
103 pub fn store_schema(&self, export_name: &str, columns: &[SchemaColumn]) -> Result<()> {
104 let json = serde_json::to_string(columns)?;
105 let now = chrono::Utc::now().to_rfc3339();
106 self.execute(
107 "INSERT INTO export_schema (export_name, columns_json, updated_at)
108 VALUES (?1, ?2, ?3)
109 ON CONFLICT(export_name) DO UPDATE SET
110 columns_json = excluded.columns_json,
111 updated_at = excluded.updated_at",
112 &[export_name.into(), json.into(), now.into()],
113 )?;
114 Ok(())
115 }
116
117 pub fn store_schema_if_absent(
126 &self,
127 export_name: &str,
128 columns: &[SchemaColumn],
129 ) -> Result<bool> {
130 if self.get_stored_schema(export_name)?.is_some() {
131 return Ok(false);
132 }
133 self.store_schema(export_name, columns)?;
134 Ok(true)
135 }
136
137 pub fn detect_schema_change(
147 &self,
148 export_name: &str,
149 current: &[SchemaColumn],
150 ) -> Result<Option<SchemaChange>> {
151 let stored = match self.get_stored_schema(export_name)? {
152 Some(s) => s,
153 None => {
154 self.store_schema(export_name, current)?;
155 return Ok(None);
156 }
157 };
158
159 let stored_map: std::collections::HashMap<&str, &str> = stored
160 .iter()
161 .map(|c| (c.name.as_str(), c.data_type.as_str()))
162 .collect();
163 let current_map: std::collections::HashMap<&str, &str> = current
164 .iter()
165 .map(|c| (c.name.as_str(), c.data_type.as_str()))
166 .collect();
167
168 let added: Vec<String> = current
169 .iter()
170 .filter(|c| !stored_map.contains_key(c.name.as_str()))
171 .map(|c| format!("{} ({})", c.name, c.data_type))
172 .collect();
173
174 let removed: Vec<String> = stored
175 .iter()
176 .filter(|c| !current_map.contains_key(c.name.as_str()))
177 .map(|c| c.name.clone())
178 .collect();
179
180 let type_changed: Vec<(String, String, String)> = current
181 .iter()
182 .filter_map(|c| {
183 stored_map.get(c.name.as_str()).and_then(|old_type| {
184 if *old_type != c.data_type.as_str() {
185 Some((c.name.clone(), old_type.to_string(), c.data_type.clone()))
186 } else {
187 None
188 }
189 })
190 })
191 .collect();
192
193 let change = SchemaChange {
194 added,
195 removed,
196 type_changed,
197 };
198
199 if change.is_empty() {
200 Ok(None)
201 } else {
202 Ok(Some(change))
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn store() -> StateStore {
212 StateStore::open_in_memory().expect("in-memory store")
213 }
214
215 #[test]
216 fn first_schema_stored_no_change() {
217 let s = store();
218 let cols = vec![
219 SchemaColumn {
220 name: "id".into(),
221 data_type: "Int64".into(),
222 },
223 SchemaColumn {
224 name: "name".into(),
225 data_type: "Utf8".into(),
226 },
227 ];
228 let change = s.detect_schema_change("orders", &cols).unwrap();
229 assert!(change.is_none(), "first run should detect no change");
230 assert!(s.get_stored_schema("orders").unwrap().is_some());
231 }
232
233 #[test]
234 fn same_schema_no_change() {
235 let s = store();
236 let cols = vec![SchemaColumn {
237 name: "id".into(),
238 data_type: "Int64".into(),
239 }];
240 s.detect_schema_change("t", &cols).unwrap();
241 let change = s.detect_schema_change("t", &cols).unwrap();
242 assert!(change.is_none());
243 }
244
245 #[test]
246 fn store_schema_if_absent_preserves_the_drift_baseline() {
247 let s = store();
256 let v1 = vec![SchemaColumn {
257 name: "id".into(),
258 data_type: "Int64".into(),
259 }];
260 let v2 = vec![
261 SchemaColumn {
262 name: "id".into(),
263 data_type: "Int64".into(),
264 },
265 SchemaColumn {
266 name: "added".into(),
267 data_type: "Utf8".into(),
268 },
269 ];
270 assert!(
272 s.store_schema_if_absent("t", &v1).unwrap(),
273 "first open: baseline absent → stored",
274 );
275 assert!(
277 !s.store_schema_if_absent("t", &v2).unwrap(),
278 "baseline present → open-forensics must preserve it, not overwrite",
279 );
280 let change = s.detect_schema_change("t", &v2).unwrap();
282 assert!(
283 change.is_some(),
284 "an added column must be detected — a store-at-open must not clobber the baseline",
285 );
286 }
287
288 #[test]
289 fn added_column_detected() {
290 let s = store();
291 let v1 = vec![SchemaColumn {
292 name: "id".into(),
293 data_type: "Int64".into(),
294 }];
295 s.detect_schema_change("t", &v1).unwrap();
296
297 let v2 = vec![
298 SchemaColumn {
299 name: "id".into(),
300 data_type: "Int64".into(),
301 },
302 SchemaColumn {
303 name: "email".into(),
304 data_type: "Utf8".into(),
305 },
306 ];
307 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
308 assert_eq!(change.added.len(), 1);
309 assert!(change.added[0].contains("email"));
310 }
311
312 #[test]
313 fn removed_column_detected() {
314 let s = store();
315 let v1 = vec![
316 SchemaColumn {
317 name: "id".into(),
318 data_type: "Int64".into(),
319 },
320 SchemaColumn {
321 name: "old_field".into(),
322 data_type: "Utf8".into(),
323 },
324 ];
325 s.detect_schema_change("t", &v1).unwrap();
326
327 let v2 = vec![SchemaColumn {
328 name: "id".into(),
329 data_type: "Int64".into(),
330 }];
331 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
332 assert_eq!(change.removed, vec!["old_field"]);
333 }
334
335 #[test]
336 fn type_change_detected() {
337 let s = store();
338 let v1 = vec![SchemaColumn {
339 name: "price".into(),
340 data_type: "Float64".into(),
341 }];
342 s.detect_schema_change("t", &v1).unwrap();
343
344 let v2 = vec![SchemaColumn {
345 name: "price".into(),
346 data_type: "Utf8".into(),
347 }];
348 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
349 assert_eq!(change.type_changed.len(), 1);
350 assert_eq!(
351 change.type_changed[0],
352 ("price".into(), "Float64".into(), "Utf8".into())
353 );
354 }
355
356 #[test]
357 fn fail_policy_does_not_store_new_schema() {
358 let s = store();
359 let v1 = vec![SchemaColumn {
360 name: "id".into(),
361 data_type: "Int64".into(),
362 }];
363 s.detect_schema_change("t", &v1).unwrap();
364
365 let v2 = vec![
366 SchemaColumn {
367 name: "id".into(),
368 data_type: "Int64".into(),
369 },
370 SchemaColumn {
371 name: "new_col".into(),
372 data_type: "Utf8".into(),
373 },
374 ];
375 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
376 assert_eq!(change.added.len(), 1);
377
378 let stored = s.get_stored_schema("t").unwrap().unwrap();
379 assert_eq!(stored.len(), 1);
380 assert_eq!(stored[0].name, "id");
381
382 let change2 = s.detect_schema_change("t", &v2).unwrap().unwrap();
383 assert_eq!(
384 change2.added.len(),
385 1,
386 "fail policy must re-detect on next run"
387 );
388 }
389
390 #[test]
391 fn warn_policy_stores_new_schema_after_change() {
392 let s = store();
393 let v1 = vec![SchemaColumn {
394 name: "id".into(),
395 data_type: "Int64".into(),
396 }];
397 s.detect_schema_change("t", &v1).unwrap();
398
399 let v2 = vec![
400 SchemaColumn {
401 name: "id".into(),
402 data_type: "Int64".into(),
403 },
404 SchemaColumn {
405 name: "extra".into(),
406 data_type: "Utf8".into(),
407 },
408 ];
409 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
410 assert_eq!(change.added.len(), 1);
411
412 s.store_schema("t", &v2).unwrap();
413
414 let no_change = s.detect_schema_change("t", &v2).unwrap();
415 assert!(
416 no_change.is_none(),
417 "after store, same schema must produce no change"
418 );
419 }
420
421 fn col(name: &str, ty: &str) -> SchemaColumn {
424 SchemaColumn {
425 name: name.into(),
426 data_type: ty.into(),
427 }
428 }
429
430 #[test]
431 fn fingerprint_format_is_xxh3_prefix_plus_16_hex() {
432 let fp = schema_fingerprint(&[col("id", "Int64")]);
433 assert!(fp.starts_with("xxh3:"), "fp = {fp}");
434 let hex = &fp["xxh3:".len()..];
435 assert_eq!(hex.len(), 16, "fp = {fp}");
436 assert!(
437 hex.chars()
438 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
439 "fp = {fp}"
440 );
441 }
442
443 #[test]
444 fn fingerprint_is_order_independent() {
445 let a = vec![col("id", "Int64"), col("name", "Utf8")];
446 let b = vec![col("name", "Utf8"), col("id", "Int64")];
447 assert_eq!(schema_fingerprint(&a), schema_fingerprint(&b));
448 }
449
450 #[test]
451 fn fingerprint_changes_on_rename() {
452 let a = vec![col("id", "Int64")];
453 let b = vec![col("user_id", "Int64")];
454 assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
455 }
456
457 #[test]
458 fn fingerprint_changes_on_retype() {
459 let a = vec![col("price", "Int64")];
460 let b = vec![col("price", "Float64")];
461 assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
462 }
463
464 #[test]
465 fn fingerprint_changes_on_column_add_or_remove() {
466 let a = vec![col("id", "Int64")];
467 let b = vec![col("id", "Int64"), col("email", "Utf8")];
468 assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
469 }
470
471 #[test]
472 fn fingerprint_is_stable_across_invocations() {
473 let cols = vec![col("a", "Int64"), col("b", "Utf8"), col("c", "Float64")];
477 let fp1 = schema_fingerprint(&cols);
478 let fp2 = schema_fingerprint(&cols);
479 let fp3 = schema_fingerprint(&cols);
480 assert_eq!(fp1, fp2);
481 assert_eq!(fp2, fp3);
482 }
483
484 #[test]
485 fn fingerprint_distinguishes_split_columns() {
486 let a = vec![col("ab", "Int64"), col("c", "Utf8")];
489 let b = vec![col("a", "Int64"), col("bc", "Utf8")];
490 assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
491 }
492
493 #[test]
494 fn fingerprint_empty_input_is_well_defined() {
495 let fp = schema_fingerprint(&[]);
499 assert!(fp.starts_with("xxh3:"));
500 }
501}