1use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13#[derive(Debug, Clone, PartialEq)]
16pub struct ColumnChange {
17 pub name: String,
19 pub from: Option<Value>,
21 pub to: Value,
23}
24
25#[derive(Debug, Clone, Default, PartialEq)]
27pub struct SchemaDiff {
28 pub additions: Vec<ColumnChange>,
30 pub widenings: Vec<ColumnChange>,
33 pub incompatible: Vec<ColumnChange>,
36 pub droppable_required: Vec<String>,
39}
40
41impl SchemaDiff {
42 pub fn is_empty(&self) -> bool {
44 self.additions.is_empty()
45 && self.widenings.is_empty()
46 && self.incompatible.is_empty()
47 && self.droppable_required.is_empty()
48 }
49
50 pub fn changed_columns(&self) -> Vec<String> {
52 self.additions
53 .iter()
54 .chain(&self.widenings)
55 .chain(&self.incompatible)
56 .map(|c| c.name.clone())
57 .chain(self.droppable_required.iter().cloned())
58 .collect()
59 }
60}
61
62#[derive(Debug, Clone, Default, PartialEq)]
65pub struct SchemaEvolution {
66 pub additions: Vec<ColumnChange>,
67 pub widenings: Vec<ColumnChange>,
68 pub relax_nullability: Vec<String>,
70}
71
72impl SchemaEvolution {
73 pub fn is_empty(&self) -> bool {
74 self.additions.is_empty() && self.widenings.is_empty() && self.relax_nullability.is_empty()
75 }
76}
77
78fn type_set(fragment: &Value) -> (Vec<String>, bool) {
81 let mut names = Vec::new();
82 let mut nullable = false;
83 match fragment.get("type") {
84 Some(Value::String(t)) => {
85 if t == "null" {
86 nullable = true
87 } else {
88 names.push(t.clone())
89 }
90 }
91 Some(Value::Array(arr)) => {
92 for v in arr {
93 if let Some(t) = v.as_str() {
94 if t == "null" {
95 nullable = true
96 } else {
97 names.push(t.to_string())
98 }
99 }
100 }
101 }
102 _ => {}
103 }
104 names.sort();
105 (names, nullable)
106}
107
108fn fits(dest: &Value, page: &Value) -> bool {
116 let (dn, dnull) = type_set(dest);
117 let (pn, pnull) = type_set(page);
118 if pnull && !dnull {
120 return false;
121 }
122 let also = also_accepts(dest);
123 pn.iter().all(|t| {
125 dn.contains(t)
126 || also.contains(&t.as_str())
127 || (t == "integer" && dn.iter().any(|d| d == "number"))
128 })
129}
130
131pub(crate) fn value_fits(dest: &Value, name: &str, value: &Value) -> bool {
135 let destination = serde_json::json!({"type": "object", "properties": {name: dest}});
136 let mut row = serde_json::Map::new();
137 row.insert(name.to_string(), value.clone());
138 let page = infer_page_schema(&destination, &[Value::Object(row)]);
139 page.get("properties")
140 .and_then(|p| p.get(name))
141 .is_some_and(|fragment| fits(dest, fragment))
142}
143
144pub const DRIFT_ALSO_ACCEPTS: &str = "x-faucet-also-accepts";
150
151fn also_accepts(dest: &Value) -> Vec<&str> {
152 dest.get(DRIFT_ALSO_ACCEPTS)
153 .and_then(Value::as_array)
154 .map(|a| a.iter().filter_map(Value::as_str).collect())
155 .unwrap_or_default()
156}
157
158fn without_also_accepted(dest: &Value, page: &Value) -> Value {
161 let also = also_accepts(dest);
162 if also.is_empty() {
163 return page.clone();
164 }
165 let (dn, _) = type_set(dest);
166 let (pn, pnull) = type_set(page);
167 let mut kept: Vec<Value> = pn
168 .into_iter()
169 .filter(|t| dn.contains(t) || !also.contains(&t.as_str()))
170 .map(Value::String)
171 .collect();
172 if pnull {
173 kept.push(Value::String("null".into()));
174 }
175 let mut out = page.clone();
176 if let Some(obj) = out.as_object_mut() {
177 let ty = match kept.len() {
178 1 => kept.remove(0),
179 _ => Value::Array(kept),
180 };
181 obj.insert("type".into(), ty);
182 }
183 out
184}
185
186fn evolvable(dest: &Value, page: &Value) -> bool {
193 let (dn, _) = type_set(dest);
194 let (pn, _) = type_set(page);
195 let mut merged: Vec<String> = dn.into_iter().chain(pn).collect();
196 merged.sort();
197 merged.dedup();
198 if merged.iter().any(|t| t == "number") {
199 merged.retain(|t| t != "integer");
200 }
201 merged.len() == 1
202}
203
204pub fn base_widened(from: &Value, to: &Value) -> bool {
212 let (dn, _) = type_set(from);
213 let (pn, _) = type_set(to);
214 let collapse = |names: Vec<String>| -> Vec<String> {
217 let mut m: Vec<String> = names;
218 m.sort();
219 m.dedup();
220 if m.iter().any(|t| t == "number") {
221 m.retain(|t| t != "integer");
222 }
223 m
224 };
225 let dest_family = collapse(dn.clone());
226 let merged = collapse(dn.into_iter().chain(pn).collect());
227 merged != dest_family
228}
229
230pub fn adds_null(from: &Value, to: &Value) -> bool {
233 let (_, fnull) = type_set(from);
234 let (_, tnull) = type_set(to);
235 tnull && !fnull
236}
237
238fn is_numeric_text(s: &str) -> bool {
241 let b = s.as_bytes();
242 let mut i = 0;
243 if b.first() == Some(&b'-') {
244 i += 1;
245 }
246 let digits = |i: &mut usize| {
247 let start = *i;
248 while *i < b.len() && b[*i].is_ascii_digit() {
249 *i += 1;
250 }
251 *i > start
252 };
253 if !digits(&mut i) {
254 return false;
255 }
256 if i < b.len() && b[i] == b'.' {
257 i += 1;
258 if !digits(&mut i) {
259 return false;
260 }
261 }
262 if i < b.len() && (b[i] == b'e' || b[i] == b'E') {
263 i += 1;
264 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
265 i += 1;
266 }
267 if !digits(&mut i) {
268 return false;
269 }
270 }
271 i == b.len()
272}
273
274pub fn infer_page_schema(destination: &Value, records: &[Value]) -> Value {
283 let mut inferred = crate::schema::infer_schema(records);
284 let Some(dest_props) = destination.get("properties").and_then(Value::as_object) else {
285 return inferred;
286 };
287 let Some(props) = inferred
288 .get_mut("properties")
289 .and_then(Value::as_object_mut)
290 else {
291 return inferred;
292 };
293 for (name, fragment) in props.iter_mut() {
294 let Some(dest) = dest_props.get(name) else {
295 continue;
296 };
297 let (dn, _) = type_set(dest);
298 let (pn, pnull) = type_set(fragment);
299 if !dn.iter().any(|t| t == "number") || !pn.iter().any(|t| t == "string") {
300 continue;
301 }
302 let all_numeric = records
303 .iter()
304 .filter_map(|r| r.get(name))
305 .all(|v| v.as_str().is_none_or(is_numeric_text));
306 if !all_numeric {
307 continue;
308 }
309 let mut names: Vec<String> = pn
310 .into_iter()
311 .map(|t| if t == "string" { "number".into() } else { t })
312 .collect();
313 if names.iter().any(|t| t == "number") {
314 names.retain(|t| t != "integer");
315 }
316 names.sort();
317 names.dedup();
318 let mut ty: Vec<Value> = names.into_iter().map(Value::String).collect();
319 if pnull {
320 ty.push(Value::String("null".into()));
321 }
322 let ty = if ty.len() == 1 {
323 ty.remove(0)
324 } else {
325 Value::Array(ty)
326 };
327 if let Some(obj) = fragment.as_object_mut() {
328 obj.insert("type".into(), ty);
329 }
330 }
331 inferred
332}
333
334pub fn diff_schema(destination: &Value, page: &Value, allow_widening: bool) -> SchemaDiff {
340 let empty = serde_json::Map::new();
341 let dest_props = destination
342 .get("properties")
343 .and_then(|p| p.as_object())
344 .unwrap_or(&empty);
345 let page_props = page
346 .get("properties")
347 .and_then(|p| p.as_object())
348 .unwrap_or(&empty);
349
350 let mut diff = SchemaDiff::default();
351
352 for (name, page_ty) in page_props {
353 match dest_props.get(name) {
354 None => diff.additions.push(ColumnChange {
355 name: name.clone(),
356 from: None,
357 to: page_ty.clone(),
358 }),
359 Some(dest_ty) => {
360 if fits(dest_ty, page_ty) {
361 continue; }
363 let page_ty = &without_also_accepted(dest_ty, page_ty);
364 let change = ColumnChange {
365 name: name.clone(),
366 from: Some(dest_ty.clone()),
367 to: page_ty.clone(),
368 };
369 if allow_widening && evolvable(dest_ty, page_ty) {
370 diff.widenings.push(change);
371 } else {
372 diff.incompatible.push(change);
373 }
374 }
375 }
376 }
377
378 for (name, dest_ty) in dest_props {
380 if !page_props.contains_key(name) {
381 let (_, nullable) = type_set(dest_ty);
382 if !nullable {
383 diff.droppable_required.push(name.clone());
384 }
385 }
386 }
387 diff.additions.sort_by(|a, b| a.name.cmp(&b.name));
388 diff.widenings.sort_by(|a, b| a.name.cmp(&b.name));
389 diff.incompatible.sort_by(|a, b| a.name.cmp(&b.name));
390 diff.droppable_required.sort();
391 diff
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
396#[serde(rename_all = "snake_case")]
397pub enum OnDrift {
398 #[default]
400 Warn,
401 Evolve,
403 Ignore,
405 Quarantine,
407 Fail,
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
414#[serde(rename_all = "snake_case")]
415pub enum OnIncompatible {
416 #[default]
418 Fail,
419 Quarantine,
421}
422
423fn default_true() -> bool {
424 true
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
429#[serde(deny_unknown_fields)]
430pub struct SchemaDriftSpec {
431 #[serde(default)]
433 pub on_drift: OnDrift,
434 #[serde(default = "default_true")]
437 pub allow_type_widening: bool,
438 #[serde(default)]
440 pub on_incompatible: OnIncompatible,
441 #[serde(default)]
456 pub relax_nullability_on_missing: bool,
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub struct SchemaDriftPolicy {
462 pub on_drift: OnDrift,
463 pub allow_widening: bool,
464 pub on_incompatible: OnIncompatible,
465 pub relax_nullability_on_missing: bool,
467}
468
469impl SchemaDriftPolicy {
470 pub fn compile(spec: &SchemaDriftSpec) -> Self {
474 Self {
475 on_drift: spec.on_drift,
476 allow_widening: spec.allow_type_widening,
477 on_incompatible: spec.on_incompatible,
478 relax_nullability_on_missing: spec.relax_nullability_on_missing,
479 }
480 }
481
482 pub fn requires_dlq(&self) -> bool {
484 self.on_drift == OnDrift::Quarantine
485 || (self.on_drift == OnDrift::Evolve
486 && self.on_incompatible == OnIncompatible::Quarantine)
487 }
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493pub enum SqlBaseType {
494 Integer,
495 Double,
496 Boolean,
497 Text,
498 Json,
500}
501
502pub fn json_schema_base_type(fragment: &Value) -> Option<SqlBaseType> {
506 let (names, _nullable) = type_set(fragment);
507 if names.iter().any(|t| t == "object" || t == "array") {
509 return Some(SqlBaseType::Json);
510 }
511 if names.iter().any(|t| t == "string") {
512 return Some(SqlBaseType::Text);
513 }
514 if names.iter().any(|t| t == "number") {
515 return Some(SqlBaseType::Double);
516 }
517 if names.iter().any(|t| t == "integer") {
518 return Some(SqlBaseType::Integer);
519 }
520 if names.iter().any(|t| t == "boolean") {
521 return Some(SqlBaseType::Boolean);
522 }
523 None
524}
525
526#[cfg(test)]
527mod tests {
528 #[test]
529 fn numeric_text_matches_exact_number_renderings_only() {
530 for ok in [
531 "0",
532 "-12",
533 "12.50",
534 "1e10",
535 "-1.5E-3",
536 "123456789012345678901234567890",
537 ] {
538 assert!(super::is_numeric_text(ok), "{ok}");
539 }
540 for bad in [
541 "", "-", "1.", ".5", "1e", "abc", "NaN", "1,5", " 1", "+1", "1e+",
542 ] {
543 assert!(!super::is_numeric_text(bad), "{bad}");
544 }
545 }
546
547 #[test]
548 fn exact_decimal_strings_fit_a_numeric_column() {
549 let dest = json!({"type": "object", "properties": {
550 "amount": {"type": "number"},
551 "n": {"type": ["number", "null"]},
552 "label": {"type": "string"},
553 "count": {"type": "integer"},
554 }});
555 let page = vec![
556 json!({"amount": "12.50", "n": null, "label": "12", "count": "7"}),
557 json!({"amount": 3, "n": "99999999999999999999", "label": "x", "count": 1}),
558 ];
559 let inferred = super::infer_page_schema(&dest, &page);
560 assert_eq!(inferred["properties"]["amount"]["type"], json!("number"));
561 assert_eq!(
562 inferred["properties"]["n"]["type"],
563 json!(["number", "null"])
564 );
565 assert_eq!(inferred["properties"]["label"]["type"], json!("string"));
566 let diff = diff_schema(&dest, &inferred, true);
567 assert!(
568 diff.incompatible
569 .iter()
570 .all(|c| c.name != "amount" && c.name != "n"),
571 "{diff:?}"
572 );
573 assert!(
575 diff.incompatible.iter().any(|c| c.name == "count"),
576 "{diff:?}"
577 );
578 }
579
580 #[test]
581 fn non_numeric_text_into_a_numeric_column_is_still_drift() {
582 let dest = json!({"type": "object", "properties": {"amount": {"type": "number"}}});
583 let page = vec![json!({"amount": "12.50"}), json!({"amount": "n/a"})];
584 let inferred = super::infer_page_schema(&dest, &page);
585 let diff = diff_schema(&dest, &inferred, true);
586 assert_eq!(diff.incompatible.len(), 1, "{diff:?}");
587 assert_eq!(
589 super::infer_page_schema(&json!({}), &page),
590 crate::schema::infer_schema(&page)
591 );
592 }
593
594 use super::*;
595
596 #[test]
597 fn value_fits_tests_one_value_against_the_destination_column() {
598 let int = json!({"type": "integer"});
599 assert!(value_fits(&int, "n", &json!(7)));
600 assert!(!value_fits(&int, "n", &json!(1.5)));
601 assert!(!value_fits(&int, "n", &json!("x")));
602 assert!(
603 !value_fits(&int, "n", &Value::Null),
604 "a NOT NULL column refuses null"
605 );
606 let nullable = json!({"type": ["number", "null"]});
607 assert!(value_fits(&nullable, "n", &json!(1.5)));
608 assert!(value_fits(&nullable, "n", &json!(7)));
609 assert!(value_fits(&nullable, "n", &Value::Null));
610 assert!(value_fits(&nullable, "n", &json!("12.50")));
612 }
613 use serde_json::json;
614
615 fn schema(props: Value) -> Value {
616 json!({ "type": "object", "properties": props })
617 }
618
619 #[test]
620 fn also_accepts_lets_extra_types_fit_without_widening() {
621 let dest = schema(json!({
622 "flag": {"type": ["integer", "null"], DRIFT_ALSO_ACCEPTS: ["boolean"]},
623 "doc": {"type": ["string", "null"], DRIFT_ALSO_ACCEPTS: ["object", "array"]},
624 }));
625 let page = schema(json!({
626 "flag": {"type": "boolean"},
627 "doc": {"type": ["object", "array"]},
628 }));
629 assert!(diff_schema(&dest, &page, true).is_empty());
630 let page = schema(json!({"flag": {"type": "number"}}));
631 let d = diff_schema(&dest, &page, true);
632 assert_eq!(d.widenings.len(), 1);
633 let page = schema(json!({"flag": {"type": ["boolean", "number"]}}));
634 let d = diff_schema(&dest, &page, true);
635 assert_eq!(d.widenings.len(), 1, "{d:?}");
636 assert_eq!(d.widenings[0].to, json!({"type": "number"}));
637 assert!(base_widened(
638 d.widenings[0].from.as_ref().unwrap(),
639 &d.widenings[0].to
640 ));
641 let strict = schema(json!({
642 "flag": {"type": "integer", DRIFT_ALSO_ACCEPTS: ["boolean"]},
643 }));
644 let page = schema(json!({"flag": {"type": ["boolean", "null"]}}));
645 let d = diff_schema(&strict, &page, true);
646 assert_eq!(d.widenings[0].to, json!({"type": "null"}));
647 assert!(adds_null(
648 d.widenings[0].from.as_ref().unwrap(),
649 &d.widenings[0].to
650 ));
651 let plain = schema(json!({"flag": {"type": ["integer", "null"]}}));
652 let page = schema(json!({"flag": {"type": "boolean"}}));
653 assert_eq!(diff_schema(&plain, &page, true).incompatible.len(), 1);
654 }
655
656 #[test]
657 fn sql_type_mapping() {
658 use super::SqlBaseType::*;
659 assert_eq!(
660 json_schema_base_type(&json!({"type":"integer"})),
661 Some(Integer)
662 );
663 assert_eq!(
664 json_schema_base_type(&json!({"type":"number"})),
665 Some(Double)
666 );
667 assert_eq!(
668 json_schema_base_type(&json!({"type":"boolean"})),
669 Some(Boolean)
670 );
671 assert_eq!(json_schema_base_type(&json!({"type":"string"})), Some(Text));
672 assert_eq!(
673 json_schema_base_type(&json!({"type":["string","null"]})),
674 Some(Text)
675 );
676 assert_eq!(json_schema_base_type(&json!({"type":"object"})), Some(Json));
677 assert_eq!(json_schema_base_type(&json!({"type":"array"})), Some(Json));
678 assert_eq!(json_schema_base_type(&json!({"type":"null"})), None);
679 }
680
681 #[test]
682 fn no_drift_when_shapes_match() {
683 let dest = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
684 let page = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
685 let d = diff_schema(&dest, &page, true);
686 assert!(d.is_empty(), "got {d:?}");
687 }
688
689 #[test]
690 fn detects_addition() {
691 let dest = schema(json!({ "id": {"type": "integer"} }));
692 let page = schema(json!({ "id": {"type": "integer"}, "email": {"type": "string"} }));
693 let d = diff_schema(&dest, &page, true);
694 assert_eq!(d.additions.len(), 1);
695 assert_eq!(d.additions[0].name, "email");
696 assert!(d.additions[0].from.is_none());
697 assert_eq!(d.additions[0].to, json!({"type": "string"}));
698 assert!(d.widenings.is_empty() && d.incompatible.is_empty());
699 }
700
701 #[test]
702 fn integer_to_number_is_widening_when_allowed() {
703 let dest = schema(json!({ "score": {"type": "integer"} }));
704 let page = schema(json!({ "score": {"type": "number"} }));
705 let d = diff_schema(&dest, &page, true);
706 assert_eq!(d.widenings.len(), 1, "got {d:?}");
707 assert_eq!(d.widenings[0].name, "score");
708 assert!(d.incompatible.is_empty());
709 }
710
711 #[test]
712 fn integer_to_number_is_incompatible_when_widening_disallowed() {
713 let dest = schema(json!({ "score": {"type": "integer"} }));
714 let page = schema(json!({ "score": {"type": "number"} }));
715 let d = diff_schema(&dest, &page, false);
716 assert_eq!(d.incompatible.len(), 1, "got {d:?}");
717 assert!(d.widenings.is_empty());
718 }
719
720 #[test]
721 fn gaining_nullability_is_widening() {
722 let dest = schema(json!({ "name": {"type": "string"} }));
723 let page = schema(json!({ "name": {"type": ["string", "null"]} }));
724 let d = diff_schema(&dest, &page, true);
725 assert_eq!(d.widenings.len(), 1, "got {d:?}");
726 }
727
728 #[test]
729 fn string_to_integer_is_incompatible() {
730 let dest = schema(json!({ "id": {"type": "string"} }));
731 let page = schema(json!({ "id": {"type": "integer"} }));
732 let d = diff_schema(&dest, &page, true);
733 assert_eq!(d.incompatible.len(), 1, "got {d:?}");
734 assert!(d.widenings.is_empty());
735 }
736
737 #[test]
738 fn required_destination_column_absent_from_page_is_droppable_required() {
739 let dest = schema(json!({
741 "id": {"type": "integer"},
742 "created_at": {"type": "string"}
743 }));
744 let page = schema(json!({ "id": {"type": "integer"} }));
745 let d = diff_schema(&dest, &page, true);
746 assert_eq!(
747 d.droppable_required,
748 vec!["created_at".to_string()],
749 "got {d:?}"
750 );
751 }
752
753 #[test]
754 fn nullable_destination_column_absent_from_page_is_not_drift() {
755 let dest = schema(json!({
757 "id": {"type": "integer"},
758 "note": {"type": ["string", "null"]}
759 }));
760 let page = schema(json!({ "id": {"type": "integer"} }));
761 let d = diff_schema(&dest, &page, true);
762 assert!(d.is_empty(), "got {d:?}");
763 }
764
765 #[test]
766 fn nested_object_treated_as_single_column() {
767 let dest =
769 schema(json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}}} }));
770 let page = schema(
771 json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "string"}}} }),
772 );
773 let d = diff_schema(&dest, &page, true);
774 assert!(
775 d.is_empty(),
776 "nested changes must not surface as drift; got {d:?}"
777 );
778 }
779
780 #[derive(Debug, PartialEq)]
782 enum Bucket {
783 None,
784 Widening,
785 Incompatible,
786 }
787
788 fn classify_one(dest_ty: Value, page_ty: Value, allow_widening: bool) -> Bucket {
790 let dest = schema(json!({ "col": dest_ty }));
791 let page = schema(json!({ "col": page_ty }));
792 let d = diff_schema(&dest, &page, allow_widening);
793 assert!(d.additions.is_empty(), "unexpected addition: {d:?}");
794 assert!(
795 d.droppable_required.is_empty(),
796 "unexpected droppable: {d:?}"
797 );
798 match (d.widenings.len(), d.incompatible.len()) {
799 (0, 0) => Bucket::None,
800 (1, 0) => Bucket::Widening,
801 (0, 1) => Bucket::Incompatible,
802 _ => panic!("ambiguous classification: {d:?}"),
803 }
804 }
805
806 #[test]
807 fn truth_table_allow_widening() {
808 use Bucket::*;
809 let cases: &[(Value, Value, Bucket)] = &[
811 (json!({"type": "integer"}), json!({"type": "integer"}), None),
812 (json!({"type": "string"}), json!({"type": "string"}), None),
813 (
814 json!({"type": ["string", "null"]}),
815 json!({"type": ["string", "null"]}),
816 None,
817 ),
818 (
820 json!({"type": ["string", "null"]}),
821 json!({"type": "string"}),
822 None,
823 ),
824 (json!({"type": "number"}), json!({"type": "integer"}), None),
826 (
828 json!({"type": "integer"}),
829 json!({"type": "number"}),
830 Widening,
831 ),
832 (
834 json!({"type": "string"}),
835 json!({"type": ["string", "null"]}),
836 Widening,
837 ),
838 (
840 json!({"type": "integer"}),
841 json!({"type": ["number", "null"]}),
842 Widening,
843 ),
844 (
846 json!({"type": ["integer", "null"]}),
847 json!({"type": "number"}),
848 Widening,
849 ),
850 (
852 json!({"type": "string"}),
853 json!({"type": "integer"}),
854 Incompatible,
855 ),
856 (
857 json!({"type": "integer"}),
858 json!({"type": "string"}),
859 Incompatible,
860 ),
861 (
862 json!({"type": "boolean"}),
863 json!({"type": "number"}),
864 Incompatible,
865 ),
866 ];
867 for (dest, page, want) in cases {
868 let got = classify_one(dest.clone(), page.clone(), true);
869 assert_eq!(
870 &got, want,
871 "allow_widening=true: D={dest} P={page} expected {want:?} got {got:?}"
872 );
873 }
874 }
875
876 #[test]
877 fn truth_table_widening_disallowed() {
878 use Bucket::*;
879 let cases: &[(Value, Value, Bucket)] = &[
881 (
883 json!({"type": "integer"}),
884 json!({"type": "number"}),
885 Incompatible,
886 ),
887 (
889 json!({"type": ["string", "null"]}),
890 json!({"type": "string"}),
891 None,
892 ),
893 (
895 json!({"type": "string"}),
896 json!({"type": ["string", "null"]}),
897 Incompatible,
898 ),
899 ];
900 for (dest, page, want) in cases {
901 let got = classify_one(dest.clone(), page.clone(), false);
902 assert_eq!(
903 &got, want,
904 "allow_widening=false: D={dest} P={page} expected {want:?} got {got:?}"
905 );
906 }
907 }
908
909 #[test]
910 fn base_widened_detects_base_type_change() {
911 assert!(base_widened(
913 &json!({"type": "integer"}),
914 &json!({"type": "number"})
915 ));
916 assert!(!base_widened(
918 &json!({"type": "string"}),
919 &json!({"type": ["string", "null"]})
920 ));
921 assert!(!base_widened(
923 &json!({"type": "integer"}),
924 &json!({"type": "integer"})
925 ));
926 assert!(base_widened(
928 &json!({"type": "integer"}),
929 &json!({"type": ["number", "null"]})
930 ));
931 assert!(!base_widened(
933 &json!({"type": "number"}),
934 &json!({"type": "integer"})
935 ));
936 }
937
938 #[test]
939 fn adds_null_detects_nullability_relaxation() {
940 assert!(adds_null(
941 &json!({"type": "string"}),
942 &json!({"type": ["string", "null"]})
943 ));
944 assert!(!adds_null(
946 &json!({"type": ["string", "null"]}),
947 &json!({"type": "string"})
948 ));
949 assert!(!adds_null(
950 &json!({"type": "string"}),
951 &json!({"type": "string"})
952 ));
953 assert!(adds_null(
955 &json!({"type": "integer"}),
956 &json!({"type": ["number", "null"]})
957 ));
958 }
959
960 #[test]
961 fn spec_defaults() {
962 let spec: SchemaDriftSpec = serde_json::from_str("{}").unwrap();
963 assert_eq!(spec.on_drift, OnDrift::Warn);
964 assert!(spec.allow_type_widening);
965 assert_eq!(spec.on_incompatible, OnIncompatible::Fail);
966 }
967
968 #[test]
969 fn on_drift_serializes_snake_case() {
970 assert_eq!(
971 serde_json::to_string(&OnDrift::Evolve).unwrap(),
972 "\"evolve\""
973 );
974 assert_eq!(
975 serde_json::to_string(&OnDrift::Quarantine).unwrap(),
976 "\"quarantine\""
977 );
978 }
979
980 #[test]
981 fn policy_compile_carries_flags() {
982 let spec: SchemaDriftSpec =
983 serde_json::from_str(r#"{"on_drift":"evolve","allow_type_widening":false}"#).unwrap();
984 let policy = SchemaDriftPolicy::compile(&spec);
985 assert_eq!(policy.on_drift, OnDrift::Evolve);
986 assert!(!policy.allow_widening);
987 assert_eq!(policy.on_incompatible, OnIncompatible::Fail);
988 }
989
990 #[test]
991 fn policy_requires_dlq_only_for_quarantine_paths() {
992 let q: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"quarantine"}"#).unwrap();
993 assert!(SchemaDriftPolicy::compile(&q).requires_dlq());
994 let evo_q: SchemaDriftSpec =
995 serde_json::from_str(r#"{"on_drift":"evolve","on_incompatible":"quarantine"}"#)
996 .unwrap();
997 assert!(SchemaDriftPolicy::compile(&evo_q).requires_dlq());
998 let warn: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"warn"}"#).unwrap();
999 assert!(!SchemaDriftPolicy::compile(&warn).requires_dlq());
1000 }
1001}