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 const DRIFT_ALSO_ACCEPTS: &str = "x-faucet-also-accepts";
137
138fn also_accepts(dest: &Value) -> Vec<&str> {
139 dest.get(DRIFT_ALSO_ACCEPTS)
140 .and_then(Value::as_array)
141 .map(|a| a.iter().filter_map(Value::as_str).collect())
142 .unwrap_or_default()
143}
144
145fn without_also_accepted(dest: &Value, page: &Value) -> Value {
148 let also = also_accepts(dest);
149 if also.is_empty() {
150 return page.clone();
151 }
152 let (dn, _) = type_set(dest);
153 let (pn, pnull) = type_set(page);
154 let mut kept: Vec<Value> = pn
155 .into_iter()
156 .filter(|t| dn.contains(t) || !also.contains(&t.as_str()))
157 .map(Value::String)
158 .collect();
159 if pnull {
160 kept.push(Value::String("null".into()));
161 }
162 let mut out = page.clone();
163 if let Some(obj) = out.as_object_mut() {
164 let ty = match kept.len() {
165 1 => kept.remove(0),
166 _ => Value::Array(kept),
167 };
168 obj.insert("type".into(), ty);
169 }
170 out
171}
172
173fn evolvable(dest: &Value, page: &Value) -> bool {
180 let (dn, _) = type_set(dest);
181 let (pn, _) = type_set(page);
182 let mut merged: Vec<String> = dn.into_iter().chain(pn).collect();
183 merged.sort();
184 merged.dedup();
185 if merged.iter().any(|t| t == "number") {
186 merged.retain(|t| t != "integer");
187 }
188 merged.len() == 1
189}
190
191pub fn base_widened(from: &Value, to: &Value) -> bool {
199 let (dn, _) = type_set(from);
200 let (pn, _) = type_set(to);
201 let collapse = |names: Vec<String>| -> Vec<String> {
204 let mut m: Vec<String> = names;
205 m.sort();
206 m.dedup();
207 if m.iter().any(|t| t == "number") {
208 m.retain(|t| t != "integer");
209 }
210 m
211 };
212 let dest_family = collapse(dn.clone());
213 let merged = collapse(dn.into_iter().chain(pn).collect());
214 merged != dest_family
215}
216
217pub fn adds_null(from: &Value, to: &Value) -> bool {
220 let (_, fnull) = type_set(from);
221 let (_, tnull) = type_set(to);
222 tnull && !fnull
223}
224
225pub fn diff_schema(destination: &Value, page: &Value, allow_widening: bool) -> SchemaDiff {
231 let empty = serde_json::Map::new();
232 let dest_props = destination
233 .get("properties")
234 .and_then(|p| p.as_object())
235 .unwrap_or(&empty);
236 let page_props = page
237 .get("properties")
238 .and_then(|p| p.as_object())
239 .unwrap_or(&empty);
240
241 let mut diff = SchemaDiff::default();
242
243 for (name, page_ty) in page_props {
244 match dest_props.get(name) {
245 None => diff.additions.push(ColumnChange {
246 name: name.clone(),
247 from: None,
248 to: page_ty.clone(),
249 }),
250 Some(dest_ty) => {
251 if fits(dest_ty, page_ty) {
252 continue; }
254 let page_ty = &without_also_accepted(dest_ty, page_ty);
255 let change = ColumnChange {
256 name: name.clone(),
257 from: Some(dest_ty.clone()),
258 to: page_ty.clone(),
259 };
260 if allow_widening && evolvable(dest_ty, page_ty) {
261 diff.widenings.push(change);
262 } else {
263 diff.incompatible.push(change);
264 }
265 }
266 }
267 }
268
269 for (name, dest_ty) in dest_props {
271 if !page_props.contains_key(name) {
272 let (_, nullable) = type_set(dest_ty);
273 if !nullable {
274 diff.droppable_required.push(name.clone());
275 }
276 }
277 }
278 diff.additions.sort_by(|a, b| a.name.cmp(&b.name));
279 diff.widenings.sort_by(|a, b| a.name.cmp(&b.name));
280 diff.incompatible.sort_by(|a, b| a.name.cmp(&b.name));
281 diff.droppable_required.sort();
282 diff
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
287#[serde(rename_all = "snake_case")]
288pub enum OnDrift {
289 #[default]
291 Warn,
292 Evolve,
294 Ignore,
296 Quarantine,
298 Fail,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
305#[serde(rename_all = "snake_case")]
306pub enum OnIncompatible {
307 #[default]
309 Fail,
310 Quarantine,
312}
313
314fn default_true() -> bool {
315 true
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
320#[serde(deny_unknown_fields)]
321pub struct SchemaDriftSpec {
322 #[serde(default)]
324 pub on_drift: OnDrift,
325 #[serde(default = "default_true")]
328 pub allow_type_widening: bool,
329 #[serde(default)]
331 pub on_incompatible: OnIncompatible,
332 #[serde(default)]
347 pub relax_nullability_on_missing: bool,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub struct SchemaDriftPolicy {
353 pub on_drift: OnDrift,
354 pub allow_widening: bool,
355 pub on_incompatible: OnIncompatible,
356 pub relax_nullability_on_missing: bool,
358}
359
360impl SchemaDriftPolicy {
361 pub fn compile(spec: &SchemaDriftSpec) -> Self {
365 Self {
366 on_drift: spec.on_drift,
367 allow_widening: spec.allow_type_widening,
368 on_incompatible: spec.on_incompatible,
369 relax_nullability_on_missing: spec.relax_nullability_on_missing,
370 }
371 }
372
373 pub fn requires_dlq(&self) -> bool {
375 self.on_drift == OnDrift::Quarantine
376 || (self.on_drift == OnDrift::Evolve
377 && self.on_incompatible == OnIncompatible::Quarantine)
378 }
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum SqlBaseType {
385 Integer,
386 Double,
387 Boolean,
388 Text,
389 Json,
391}
392
393pub fn json_schema_base_type(fragment: &Value) -> Option<SqlBaseType> {
397 let (names, _nullable) = type_set(fragment);
398 if names.iter().any(|t| t == "object" || t == "array") {
400 return Some(SqlBaseType::Json);
401 }
402 if names.iter().any(|t| t == "string") {
403 return Some(SqlBaseType::Text);
404 }
405 if names.iter().any(|t| t == "number") {
406 return Some(SqlBaseType::Double);
407 }
408 if names.iter().any(|t| t == "integer") {
409 return Some(SqlBaseType::Integer);
410 }
411 if names.iter().any(|t| t == "boolean") {
412 return Some(SqlBaseType::Boolean);
413 }
414 None
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use serde_json::json;
421
422 fn schema(props: Value) -> Value {
423 json!({ "type": "object", "properties": props })
424 }
425
426 #[test]
427 fn also_accepts_lets_extra_types_fit_without_widening() {
428 let dest = schema(json!({
429 "flag": {"type": ["integer", "null"], DRIFT_ALSO_ACCEPTS: ["boolean"]},
430 "doc": {"type": ["string", "null"], DRIFT_ALSO_ACCEPTS: ["object", "array"]},
431 }));
432 let page = schema(json!({
433 "flag": {"type": "boolean"},
434 "doc": {"type": ["object", "array"]},
435 }));
436 assert!(diff_schema(&dest, &page, true).is_empty());
437 let page = schema(json!({"flag": {"type": "number"}}));
438 let d = diff_schema(&dest, &page, true);
439 assert_eq!(d.widenings.len(), 1);
440 let page = schema(json!({"flag": {"type": ["boolean", "number"]}}));
441 let d = diff_schema(&dest, &page, true);
442 assert_eq!(d.widenings.len(), 1, "{d:?}");
443 assert_eq!(d.widenings[0].to, json!({"type": "number"}));
444 assert!(base_widened(
445 d.widenings[0].from.as_ref().unwrap(),
446 &d.widenings[0].to
447 ));
448 let strict = schema(json!({
449 "flag": {"type": "integer", DRIFT_ALSO_ACCEPTS: ["boolean"]},
450 }));
451 let page = schema(json!({"flag": {"type": ["boolean", "null"]}}));
452 let d = diff_schema(&strict, &page, true);
453 assert_eq!(d.widenings[0].to, json!({"type": "null"}));
454 assert!(adds_null(
455 d.widenings[0].from.as_ref().unwrap(),
456 &d.widenings[0].to
457 ));
458 let plain = schema(json!({"flag": {"type": ["integer", "null"]}}));
459 let page = schema(json!({"flag": {"type": "boolean"}}));
460 assert_eq!(diff_schema(&plain, &page, true).incompatible.len(), 1);
461 }
462
463 #[test]
464 fn sql_type_mapping() {
465 use super::SqlBaseType::*;
466 assert_eq!(
467 json_schema_base_type(&json!({"type":"integer"})),
468 Some(Integer)
469 );
470 assert_eq!(
471 json_schema_base_type(&json!({"type":"number"})),
472 Some(Double)
473 );
474 assert_eq!(
475 json_schema_base_type(&json!({"type":"boolean"})),
476 Some(Boolean)
477 );
478 assert_eq!(json_schema_base_type(&json!({"type":"string"})), Some(Text));
479 assert_eq!(
480 json_schema_base_type(&json!({"type":["string","null"]})),
481 Some(Text)
482 );
483 assert_eq!(json_schema_base_type(&json!({"type":"object"})), Some(Json));
484 assert_eq!(json_schema_base_type(&json!({"type":"array"})), Some(Json));
485 assert_eq!(json_schema_base_type(&json!({"type":"null"})), None);
486 }
487
488 #[test]
489 fn no_drift_when_shapes_match() {
490 let dest = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
491 let page = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
492 let d = diff_schema(&dest, &page, true);
493 assert!(d.is_empty(), "got {d:?}");
494 }
495
496 #[test]
497 fn detects_addition() {
498 let dest = schema(json!({ "id": {"type": "integer"} }));
499 let page = schema(json!({ "id": {"type": "integer"}, "email": {"type": "string"} }));
500 let d = diff_schema(&dest, &page, true);
501 assert_eq!(d.additions.len(), 1);
502 assert_eq!(d.additions[0].name, "email");
503 assert!(d.additions[0].from.is_none());
504 assert_eq!(d.additions[0].to, json!({"type": "string"}));
505 assert!(d.widenings.is_empty() && d.incompatible.is_empty());
506 }
507
508 #[test]
509 fn integer_to_number_is_widening_when_allowed() {
510 let dest = schema(json!({ "score": {"type": "integer"} }));
511 let page = schema(json!({ "score": {"type": "number"} }));
512 let d = diff_schema(&dest, &page, true);
513 assert_eq!(d.widenings.len(), 1, "got {d:?}");
514 assert_eq!(d.widenings[0].name, "score");
515 assert!(d.incompatible.is_empty());
516 }
517
518 #[test]
519 fn integer_to_number_is_incompatible_when_widening_disallowed() {
520 let dest = schema(json!({ "score": {"type": "integer"} }));
521 let page = schema(json!({ "score": {"type": "number"} }));
522 let d = diff_schema(&dest, &page, false);
523 assert_eq!(d.incompatible.len(), 1, "got {d:?}");
524 assert!(d.widenings.is_empty());
525 }
526
527 #[test]
528 fn gaining_nullability_is_widening() {
529 let dest = schema(json!({ "name": {"type": "string"} }));
530 let page = schema(json!({ "name": {"type": ["string", "null"]} }));
531 let d = diff_schema(&dest, &page, true);
532 assert_eq!(d.widenings.len(), 1, "got {d:?}");
533 }
534
535 #[test]
536 fn string_to_integer_is_incompatible() {
537 let dest = schema(json!({ "id": {"type": "string"} }));
538 let page = schema(json!({ "id": {"type": "integer"} }));
539 let d = diff_schema(&dest, &page, true);
540 assert_eq!(d.incompatible.len(), 1, "got {d:?}");
541 assert!(d.widenings.is_empty());
542 }
543
544 #[test]
545 fn required_destination_column_absent_from_page_is_droppable_required() {
546 let dest = schema(json!({
548 "id": {"type": "integer"},
549 "created_at": {"type": "string"}
550 }));
551 let page = schema(json!({ "id": {"type": "integer"} }));
552 let d = diff_schema(&dest, &page, true);
553 assert_eq!(
554 d.droppable_required,
555 vec!["created_at".to_string()],
556 "got {d:?}"
557 );
558 }
559
560 #[test]
561 fn nullable_destination_column_absent_from_page_is_not_drift() {
562 let dest = schema(json!({
564 "id": {"type": "integer"},
565 "note": {"type": ["string", "null"]}
566 }));
567 let page = schema(json!({ "id": {"type": "integer"} }));
568 let d = diff_schema(&dest, &page, true);
569 assert!(d.is_empty(), "got {d:?}");
570 }
571
572 #[test]
573 fn nested_object_treated_as_single_column() {
574 let dest =
576 schema(json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}}} }));
577 let page = schema(
578 json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "string"}}} }),
579 );
580 let d = diff_schema(&dest, &page, true);
581 assert!(
582 d.is_empty(),
583 "nested changes must not surface as drift; got {d:?}"
584 );
585 }
586
587 #[derive(Debug, PartialEq)]
589 enum Bucket {
590 None,
591 Widening,
592 Incompatible,
593 }
594
595 fn classify_one(dest_ty: Value, page_ty: Value, allow_widening: bool) -> Bucket {
597 let dest = schema(json!({ "col": dest_ty }));
598 let page = schema(json!({ "col": page_ty }));
599 let d = diff_schema(&dest, &page, allow_widening);
600 assert!(d.additions.is_empty(), "unexpected addition: {d:?}");
601 assert!(
602 d.droppable_required.is_empty(),
603 "unexpected droppable: {d:?}"
604 );
605 match (d.widenings.len(), d.incompatible.len()) {
606 (0, 0) => Bucket::None,
607 (1, 0) => Bucket::Widening,
608 (0, 1) => Bucket::Incompatible,
609 _ => panic!("ambiguous classification: {d:?}"),
610 }
611 }
612
613 #[test]
614 fn truth_table_allow_widening() {
615 use Bucket::*;
616 let cases: &[(Value, Value, Bucket)] = &[
618 (json!({"type": "integer"}), json!({"type": "integer"}), None),
619 (json!({"type": "string"}), json!({"type": "string"}), None),
620 (
621 json!({"type": ["string", "null"]}),
622 json!({"type": ["string", "null"]}),
623 None,
624 ),
625 (
627 json!({"type": ["string", "null"]}),
628 json!({"type": "string"}),
629 None,
630 ),
631 (json!({"type": "number"}), json!({"type": "integer"}), None),
633 (
635 json!({"type": "integer"}),
636 json!({"type": "number"}),
637 Widening,
638 ),
639 (
641 json!({"type": "string"}),
642 json!({"type": ["string", "null"]}),
643 Widening,
644 ),
645 (
647 json!({"type": "integer"}),
648 json!({"type": ["number", "null"]}),
649 Widening,
650 ),
651 (
653 json!({"type": ["integer", "null"]}),
654 json!({"type": "number"}),
655 Widening,
656 ),
657 (
659 json!({"type": "string"}),
660 json!({"type": "integer"}),
661 Incompatible,
662 ),
663 (
664 json!({"type": "integer"}),
665 json!({"type": "string"}),
666 Incompatible,
667 ),
668 (
669 json!({"type": "boolean"}),
670 json!({"type": "number"}),
671 Incompatible,
672 ),
673 ];
674 for (dest, page, want) in cases {
675 let got = classify_one(dest.clone(), page.clone(), true);
676 assert_eq!(
677 &got, want,
678 "allow_widening=true: D={dest} P={page} expected {want:?} got {got:?}"
679 );
680 }
681 }
682
683 #[test]
684 fn truth_table_widening_disallowed() {
685 use Bucket::*;
686 let cases: &[(Value, Value, Bucket)] = &[
688 (
690 json!({"type": "integer"}),
691 json!({"type": "number"}),
692 Incompatible,
693 ),
694 (
696 json!({"type": ["string", "null"]}),
697 json!({"type": "string"}),
698 None,
699 ),
700 (
702 json!({"type": "string"}),
703 json!({"type": ["string", "null"]}),
704 Incompatible,
705 ),
706 ];
707 for (dest, page, want) in cases {
708 let got = classify_one(dest.clone(), page.clone(), false);
709 assert_eq!(
710 &got, want,
711 "allow_widening=false: D={dest} P={page} expected {want:?} got {got:?}"
712 );
713 }
714 }
715
716 #[test]
717 fn base_widened_detects_base_type_change() {
718 assert!(base_widened(
720 &json!({"type": "integer"}),
721 &json!({"type": "number"})
722 ));
723 assert!(!base_widened(
725 &json!({"type": "string"}),
726 &json!({"type": ["string", "null"]})
727 ));
728 assert!(!base_widened(
730 &json!({"type": "integer"}),
731 &json!({"type": "integer"})
732 ));
733 assert!(base_widened(
735 &json!({"type": "integer"}),
736 &json!({"type": ["number", "null"]})
737 ));
738 assert!(!base_widened(
740 &json!({"type": "number"}),
741 &json!({"type": "integer"})
742 ));
743 }
744
745 #[test]
746 fn adds_null_detects_nullability_relaxation() {
747 assert!(adds_null(
748 &json!({"type": "string"}),
749 &json!({"type": ["string", "null"]})
750 ));
751 assert!(!adds_null(
753 &json!({"type": ["string", "null"]}),
754 &json!({"type": "string"})
755 ));
756 assert!(!adds_null(
757 &json!({"type": "string"}),
758 &json!({"type": "string"})
759 ));
760 assert!(adds_null(
762 &json!({"type": "integer"}),
763 &json!({"type": ["number", "null"]})
764 ));
765 }
766
767 #[test]
768 fn spec_defaults() {
769 let spec: SchemaDriftSpec = serde_json::from_str("{}").unwrap();
770 assert_eq!(spec.on_drift, OnDrift::Warn);
771 assert!(spec.allow_type_widening);
772 assert_eq!(spec.on_incompatible, OnIncompatible::Fail);
773 }
774
775 #[test]
776 fn on_drift_serializes_snake_case() {
777 assert_eq!(
778 serde_json::to_string(&OnDrift::Evolve).unwrap(),
779 "\"evolve\""
780 );
781 assert_eq!(
782 serde_json::to_string(&OnDrift::Quarantine).unwrap(),
783 "\"quarantine\""
784 );
785 }
786
787 #[test]
788 fn policy_compile_carries_flags() {
789 let spec: SchemaDriftSpec =
790 serde_json::from_str(r#"{"on_drift":"evolve","allow_type_widening":false}"#).unwrap();
791 let policy = SchemaDriftPolicy::compile(&spec);
792 assert_eq!(policy.on_drift, OnDrift::Evolve);
793 assert!(!policy.allow_widening);
794 assert_eq!(policy.on_incompatible, OnIncompatible::Fail);
795 }
796
797 #[test]
798 fn policy_requires_dlq_only_for_quarantine_paths() {
799 let q: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"quarantine"}"#).unwrap();
800 assert!(SchemaDriftPolicy::compile(&q).requires_dlq());
801 let evo_q: SchemaDriftSpec =
802 serde_json::from_str(r#"{"on_drift":"evolve","on_incompatible":"quarantine"}"#)
803 .unwrap();
804 assert!(SchemaDriftPolicy::compile(&evo_q).requires_dlq());
805 let warn: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"warn"}"#).unwrap();
806 assert!(!SchemaDriftPolicy::compile(&warn).requires_dlq());
807 }
808}