1use crate::error::Result;
2
3use super::{StateConn, StateStore, pg_sql};
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 match &self.conn {
93 StateConn::Sqlite(c) => {
94 let mut stmt =
95 c.prepare("SELECT columns_json FROM export_schema WHERE export_name = ?1")?;
96 let result = stmt.query_row([export_name], |row| {
97 let json_str: String = row.get(0)?;
98 Ok(json_str)
99 });
100 match result {
101 Ok(json_str) => {
102 let cols: Vec<SchemaColumn> = serde_json::from_str(&json_str)?;
103 Ok(Some(cols))
104 }
105 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
106 Err(e) => Err(e.into()),
107 }
108 }
109 StateConn::Postgres(client) => {
110 let mut c = client.borrow_mut();
111 match c.query_opt(
112 "SELECT columns_json FROM export_schema WHERE export_name = $1",
113 &[&export_name],
114 )? {
115 Some(row) => {
116 let json_str: String = row.get(0);
117 let cols: Vec<SchemaColumn> = serde_json::from_str(&json_str)?;
118 Ok(Some(cols))
119 }
120 None => Ok(None),
121 }
122 }
123 }
124 }
125
126 pub fn store_schema(&self, export_name: &str, columns: &[SchemaColumn]) -> Result<()> {
127 let json = serde_json::to_string(columns)?;
128 let now = chrono::Utc::now().to_rfc3339();
129 let sql = "INSERT INTO export_schema (export_name, columns_json, updated_at)
130 VALUES (?1, ?2, ?3)
131 ON CONFLICT(export_name) DO UPDATE SET
132 columns_json = excluded.columns_json,
133 updated_at = excluded.updated_at";
134 match &self.conn {
135 StateConn::Sqlite(c) => {
136 c.execute(sql, rusqlite::params![export_name, json, now])?;
137 }
138 StateConn::Postgres(client) => {
139 let mut c = client.borrow_mut();
140 c.execute(&pg_sql(sql), &[&export_name, &json, &now])?;
141 }
142 }
143 Ok(())
144 }
145
146 pub fn store_schema_if_absent(
155 &self,
156 export_name: &str,
157 columns: &[SchemaColumn],
158 ) -> Result<bool> {
159 if self.get_stored_schema(export_name)?.is_some() {
160 return Ok(false);
161 }
162 self.store_schema(export_name, columns)?;
163 Ok(true)
164 }
165
166 pub fn detect_schema_change(
176 &self,
177 export_name: &str,
178 current: &[SchemaColumn],
179 ) -> Result<Option<SchemaChange>> {
180 let stored = match self.get_stored_schema(export_name)? {
181 Some(s) => s,
182 None => {
183 self.store_schema(export_name, current)?;
184 return Ok(None);
185 }
186 };
187
188 let stored_map: std::collections::HashMap<&str, &str> = stored
189 .iter()
190 .map(|c| (c.name.as_str(), c.data_type.as_str()))
191 .collect();
192 let current_map: std::collections::HashMap<&str, &str> = current
193 .iter()
194 .map(|c| (c.name.as_str(), c.data_type.as_str()))
195 .collect();
196
197 let added: Vec<String> = current
198 .iter()
199 .filter(|c| !stored_map.contains_key(c.name.as_str()))
200 .map(|c| format!("{} ({})", c.name, c.data_type))
201 .collect();
202
203 let removed: Vec<String> = stored
204 .iter()
205 .filter(|c| !current_map.contains_key(c.name.as_str()))
206 .map(|c| c.name.clone())
207 .collect();
208
209 let type_changed: Vec<(String, String, String)> = current
210 .iter()
211 .filter_map(|c| {
212 stored_map.get(c.name.as_str()).and_then(|old_type| {
213 if *old_type != c.data_type.as_str() {
214 Some((c.name.clone(), old_type.to_string(), c.data_type.clone()))
215 } else {
216 None
217 }
218 })
219 })
220 .collect();
221
222 let change = SchemaChange {
223 added,
224 removed,
225 type_changed,
226 };
227
228 if change.is_empty() {
229 Ok(None)
230 } else {
231 Ok(Some(change))
232 }
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn store() -> StateStore {
241 StateStore::open_in_memory().expect("in-memory store")
242 }
243
244 #[test]
245 fn first_schema_stored_no_change() {
246 let s = store();
247 let cols = vec![
248 SchemaColumn {
249 name: "id".into(),
250 data_type: "Int64".into(),
251 },
252 SchemaColumn {
253 name: "name".into(),
254 data_type: "Utf8".into(),
255 },
256 ];
257 let change = s.detect_schema_change("orders", &cols).unwrap();
258 assert!(change.is_none(), "first run should detect no change");
259 assert!(s.get_stored_schema("orders").unwrap().is_some());
260 }
261
262 #[test]
263 fn same_schema_no_change() {
264 let s = store();
265 let cols = vec![SchemaColumn {
266 name: "id".into(),
267 data_type: "Int64".into(),
268 }];
269 s.detect_schema_change("t", &cols).unwrap();
270 let change = s.detect_schema_change("t", &cols).unwrap();
271 assert!(change.is_none());
272 }
273
274 #[test]
275 fn store_schema_if_absent_preserves_the_drift_baseline() {
276 let s = store();
285 let v1 = vec![SchemaColumn {
286 name: "id".into(),
287 data_type: "Int64".into(),
288 }];
289 let v2 = vec![
290 SchemaColumn {
291 name: "id".into(),
292 data_type: "Int64".into(),
293 },
294 SchemaColumn {
295 name: "added".into(),
296 data_type: "Utf8".into(),
297 },
298 ];
299 assert!(
301 s.store_schema_if_absent("t", &v1).unwrap(),
302 "first open: baseline absent → stored",
303 );
304 assert!(
306 !s.store_schema_if_absent("t", &v2).unwrap(),
307 "baseline present → open-forensics must preserve it, not overwrite",
308 );
309 let change = s.detect_schema_change("t", &v2).unwrap();
311 assert!(
312 change.is_some(),
313 "an added column must be detected — a store-at-open must not clobber the baseline",
314 );
315 }
316
317 #[test]
318 fn added_column_detected() {
319 let s = store();
320 let v1 = vec![SchemaColumn {
321 name: "id".into(),
322 data_type: "Int64".into(),
323 }];
324 s.detect_schema_change("t", &v1).unwrap();
325
326 let v2 = vec![
327 SchemaColumn {
328 name: "id".into(),
329 data_type: "Int64".into(),
330 },
331 SchemaColumn {
332 name: "email".into(),
333 data_type: "Utf8".into(),
334 },
335 ];
336 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
337 assert_eq!(change.added.len(), 1);
338 assert!(change.added[0].contains("email"));
339 }
340
341 #[test]
342 fn removed_column_detected() {
343 let s = store();
344 let v1 = vec![
345 SchemaColumn {
346 name: "id".into(),
347 data_type: "Int64".into(),
348 },
349 SchemaColumn {
350 name: "old_field".into(),
351 data_type: "Utf8".into(),
352 },
353 ];
354 s.detect_schema_change("t", &v1).unwrap();
355
356 let v2 = vec![SchemaColumn {
357 name: "id".into(),
358 data_type: "Int64".into(),
359 }];
360 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
361 assert_eq!(change.removed, vec!["old_field"]);
362 }
363
364 #[test]
365 fn type_change_detected() {
366 let s = store();
367 let v1 = vec![SchemaColumn {
368 name: "price".into(),
369 data_type: "Float64".into(),
370 }];
371 s.detect_schema_change("t", &v1).unwrap();
372
373 let v2 = vec![SchemaColumn {
374 name: "price".into(),
375 data_type: "Utf8".into(),
376 }];
377 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
378 assert_eq!(change.type_changed.len(), 1);
379 assert_eq!(
380 change.type_changed[0],
381 ("price".into(), "Float64".into(), "Utf8".into())
382 );
383 }
384
385 #[test]
386 fn fail_policy_does_not_store_new_schema() {
387 let s = store();
388 let v1 = vec![SchemaColumn {
389 name: "id".into(),
390 data_type: "Int64".into(),
391 }];
392 s.detect_schema_change("t", &v1).unwrap();
393
394 let v2 = vec![
395 SchemaColumn {
396 name: "id".into(),
397 data_type: "Int64".into(),
398 },
399 SchemaColumn {
400 name: "new_col".into(),
401 data_type: "Utf8".into(),
402 },
403 ];
404 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
405 assert_eq!(change.added.len(), 1);
406
407 let stored = s.get_stored_schema("t").unwrap().unwrap();
408 assert_eq!(stored.len(), 1);
409 assert_eq!(stored[0].name, "id");
410
411 let change2 = s.detect_schema_change("t", &v2).unwrap().unwrap();
412 assert_eq!(
413 change2.added.len(),
414 1,
415 "fail policy must re-detect on next run"
416 );
417 }
418
419 #[test]
420 fn warn_policy_stores_new_schema_after_change() {
421 let s = store();
422 let v1 = vec![SchemaColumn {
423 name: "id".into(),
424 data_type: "Int64".into(),
425 }];
426 s.detect_schema_change("t", &v1).unwrap();
427
428 let v2 = vec![
429 SchemaColumn {
430 name: "id".into(),
431 data_type: "Int64".into(),
432 },
433 SchemaColumn {
434 name: "extra".into(),
435 data_type: "Utf8".into(),
436 },
437 ];
438 let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
439 assert_eq!(change.added.len(), 1);
440
441 s.store_schema("t", &v2).unwrap();
442
443 let no_change = s.detect_schema_change("t", &v2).unwrap();
444 assert!(
445 no_change.is_none(),
446 "after store, same schema must produce no change"
447 );
448 }
449
450 fn col(name: &str, ty: &str) -> SchemaColumn {
453 SchemaColumn {
454 name: name.into(),
455 data_type: ty.into(),
456 }
457 }
458
459 #[test]
460 fn fingerprint_format_is_xxh3_prefix_plus_16_hex() {
461 let fp = schema_fingerprint(&[col("id", "Int64")]);
462 assert!(fp.starts_with("xxh3:"), "fp = {fp}");
463 let hex = &fp["xxh3:".len()..];
464 assert_eq!(hex.len(), 16, "fp = {fp}");
465 assert!(
466 hex.chars()
467 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
468 "fp = {fp}"
469 );
470 }
471
472 #[test]
473 fn fingerprint_is_order_independent() {
474 let a = vec![col("id", "Int64"), col("name", "Utf8")];
475 let b = vec![col("name", "Utf8"), col("id", "Int64")];
476 assert_eq!(schema_fingerprint(&a), schema_fingerprint(&b));
477 }
478
479 #[test]
480 fn fingerprint_changes_on_rename() {
481 let a = vec![col("id", "Int64")];
482 let b = vec![col("user_id", "Int64")];
483 assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
484 }
485
486 #[test]
487 fn fingerprint_changes_on_retype() {
488 let a = vec![col("price", "Int64")];
489 let b = vec![col("price", "Float64")];
490 assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
491 }
492
493 #[test]
494 fn fingerprint_changes_on_column_add_or_remove() {
495 let a = vec![col("id", "Int64")];
496 let b = vec![col("id", "Int64"), col("email", "Utf8")];
497 assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
498 }
499
500 #[test]
501 fn fingerprint_is_stable_across_invocations() {
502 let cols = vec![col("a", "Int64"), col("b", "Utf8"), col("c", "Float64")];
506 let fp1 = schema_fingerprint(&cols);
507 let fp2 = schema_fingerprint(&cols);
508 let fp3 = schema_fingerprint(&cols);
509 assert_eq!(fp1, fp2);
510 assert_eq!(fp2, fp3);
511 }
512
513 #[test]
514 fn fingerprint_distinguishes_split_columns() {
515 let a = vec![col("ab", "Int64"), col("c", "Utf8")];
518 let b = vec![col("a", "Int64"), col("bc", "Utf8")];
519 assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
520 }
521
522 #[test]
523 fn fingerprint_empty_input_is_well_defined() {
524 let fp = schema_fingerprint(&[]);
528 assert!(fp.starts_with("xxh3:"));
529 }
530}