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: Vec<&str> = dest
123 .get(DRIFT_ALSO_ACCEPTS)
124 .and_then(Value::as_array)
125 .map(|a| a.iter().filter_map(Value::as_str).collect())
126 .unwrap_or_default();
127 pn.iter().all(|t| {
129 dn.contains(t)
130 || also.contains(&t.as_str())
131 || (t == "integer" && dn.iter().any(|d| d == "number"))
132 })
133}
134
135pub const DRIFT_ALSO_ACCEPTS: &str = "x-faucet-also-accepts";
141
142fn evolvable(dest: &Value, page: &Value) -> bool {
149 let (dn, _) = type_set(dest);
150 let (pn, _) = type_set(page);
151 let mut merged: Vec<String> = dn.into_iter().chain(pn).collect();
152 merged.sort();
153 merged.dedup();
154 if merged.iter().any(|t| t == "number") {
155 merged.retain(|t| t != "integer");
156 }
157 merged.len() == 1
158}
159
160pub fn base_widened(from: &Value, to: &Value) -> bool {
168 let (dn, _) = type_set(from);
169 let (pn, _) = type_set(to);
170 let collapse = |names: Vec<String>| -> Vec<String> {
173 let mut m: Vec<String> = names;
174 m.sort();
175 m.dedup();
176 if m.iter().any(|t| t == "number") {
177 m.retain(|t| t != "integer");
178 }
179 m
180 };
181 let dest_family = collapse(dn.clone());
182 let merged = collapse(dn.into_iter().chain(pn).collect());
183 merged != dest_family
184}
185
186pub fn adds_null(from: &Value, to: &Value) -> bool {
189 let (_, fnull) = type_set(from);
190 let (_, tnull) = type_set(to);
191 tnull && !fnull
192}
193
194pub fn diff_schema(destination: &Value, page: &Value, allow_widening: bool) -> SchemaDiff {
200 let empty = serde_json::Map::new();
201 let dest_props = destination
202 .get("properties")
203 .and_then(|p| p.as_object())
204 .unwrap_or(&empty);
205 let page_props = page
206 .get("properties")
207 .and_then(|p| p.as_object())
208 .unwrap_or(&empty);
209
210 let mut diff = SchemaDiff::default();
211
212 for (name, page_ty) in page_props {
213 match dest_props.get(name) {
214 None => diff.additions.push(ColumnChange {
215 name: name.clone(),
216 from: None,
217 to: page_ty.clone(),
218 }),
219 Some(dest_ty) => {
220 if fits(dest_ty, page_ty) {
221 continue; }
223 let change = ColumnChange {
224 name: name.clone(),
225 from: Some(dest_ty.clone()),
226 to: page_ty.clone(),
227 };
228 if allow_widening && evolvable(dest_ty, page_ty) {
229 diff.widenings.push(change);
230 } else {
231 diff.incompatible.push(change);
232 }
233 }
234 }
235 }
236
237 for (name, dest_ty) in dest_props {
239 if !page_props.contains_key(name) {
240 let (_, nullable) = type_set(dest_ty);
241 if !nullable {
242 diff.droppable_required.push(name.clone());
243 }
244 }
245 }
246 diff.additions.sort_by(|a, b| a.name.cmp(&b.name));
247 diff.widenings.sort_by(|a, b| a.name.cmp(&b.name));
248 diff.incompatible.sort_by(|a, b| a.name.cmp(&b.name));
249 diff.droppable_required.sort();
250 diff
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
255#[serde(rename_all = "snake_case")]
256pub enum OnDrift {
257 #[default]
259 Warn,
260 Evolve,
262 Ignore,
264 Quarantine,
266 Fail,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
273#[serde(rename_all = "snake_case")]
274pub enum OnIncompatible {
275 #[default]
277 Fail,
278 Quarantine,
280}
281
282fn default_true() -> bool {
283 true
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
288#[serde(deny_unknown_fields)]
289pub struct SchemaDriftSpec {
290 #[serde(default)]
292 pub on_drift: OnDrift,
293 #[serde(default = "default_true")]
296 pub allow_type_widening: bool,
297 #[serde(default)]
299 pub on_incompatible: OnIncompatible,
300 #[serde(default)]
315 pub relax_nullability_on_missing: bool,
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub struct SchemaDriftPolicy {
321 pub on_drift: OnDrift,
322 pub allow_widening: bool,
323 pub on_incompatible: OnIncompatible,
324 pub relax_nullability_on_missing: bool,
326}
327
328impl SchemaDriftPolicy {
329 pub fn compile(spec: &SchemaDriftSpec) -> Self {
333 Self {
334 on_drift: spec.on_drift,
335 allow_widening: spec.allow_type_widening,
336 on_incompatible: spec.on_incompatible,
337 relax_nullability_on_missing: spec.relax_nullability_on_missing,
338 }
339 }
340
341 pub fn requires_dlq(&self) -> bool {
343 self.on_drift == OnDrift::Quarantine
344 || (self.on_drift == OnDrift::Evolve
345 && self.on_incompatible == OnIncompatible::Quarantine)
346 }
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum SqlBaseType {
353 Integer,
354 Double,
355 Boolean,
356 Text,
357 Json,
359}
360
361pub fn json_schema_base_type(fragment: &Value) -> Option<SqlBaseType> {
365 let (names, _nullable) = type_set(fragment);
366 if names.iter().any(|t| t == "object" || t == "array") {
368 return Some(SqlBaseType::Json);
369 }
370 if names.iter().any(|t| t == "string") {
371 return Some(SqlBaseType::Text);
372 }
373 if names.iter().any(|t| t == "number") {
374 return Some(SqlBaseType::Double);
375 }
376 if names.iter().any(|t| t == "integer") {
377 return Some(SqlBaseType::Integer);
378 }
379 if names.iter().any(|t| t == "boolean") {
380 return Some(SqlBaseType::Boolean);
381 }
382 None
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use serde_json::json;
389
390 fn schema(props: Value) -> Value {
391 json!({ "type": "object", "properties": props })
392 }
393
394 #[test]
395 fn also_accepts_lets_extra_types_fit_without_widening() {
396 let dest = schema(json!({
397 "flag": {"type": ["integer", "null"], DRIFT_ALSO_ACCEPTS: ["boolean"]},
398 "doc": {"type": ["string", "null"], DRIFT_ALSO_ACCEPTS: ["object", "array"]},
399 }));
400 let page = schema(json!({
401 "flag": {"type": "boolean"},
402 "doc": {"type": ["object", "array"]},
403 }));
404 assert!(diff_schema(&dest, &page, true).is_empty());
405 let page = schema(json!({"flag": {"type": "number"}}));
406 let d = diff_schema(&dest, &page, true);
407 assert_eq!(d.widenings.len(), 1);
408 let plain = schema(json!({"flag": {"type": ["integer", "null"]}}));
409 let page = schema(json!({"flag": {"type": "boolean"}}));
410 assert_eq!(diff_schema(&plain, &page, true).incompatible.len(), 1);
411 }
412
413 #[test]
414 fn sql_type_mapping() {
415 use super::SqlBaseType::*;
416 assert_eq!(
417 json_schema_base_type(&json!({"type":"integer"})),
418 Some(Integer)
419 );
420 assert_eq!(
421 json_schema_base_type(&json!({"type":"number"})),
422 Some(Double)
423 );
424 assert_eq!(
425 json_schema_base_type(&json!({"type":"boolean"})),
426 Some(Boolean)
427 );
428 assert_eq!(json_schema_base_type(&json!({"type":"string"})), Some(Text));
429 assert_eq!(
430 json_schema_base_type(&json!({"type":["string","null"]})),
431 Some(Text)
432 );
433 assert_eq!(json_schema_base_type(&json!({"type":"object"})), Some(Json));
434 assert_eq!(json_schema_base_type(&json!({"type":"array"})), Some(Json));
435 assert_eq!(json_schema_base_type(&json!({"type":"null"})), None);
436 }
437
438 #[test]
439 fn no_drift_when_shapes_match() {
440 let dest = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
441 let page = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
442 let d = diff_schema(&dest, &page, true);
443 assert!(d.is_empty(), "got {d:?}");
444 }
445
446 #[test]
447 fn detects_addition() {
448 let dest = schema(json!({ "id": {"type": "integer"} }));
449 let page = schema(json!({ "id": {"type": "integer"}, "email": {"type": "string"} }));
450 let d = diff_schema(&dest, &page, true);
451 assert_eq!(d.additions.len(), 1);
452 assert_eq!(d.additions[0].name, "email");
453 assert!(d.additions[0].from.is_none());
454 assert_eq!(d.additions[0].to, json!({"type": "string"}));
455 assert!(d.widenings.is_empty() && d.incompatible.is_empty());
456 }
457
458 #[test]
459 fn integer_to_number_is_widening_when_allowed() {
460 let dest = schema(json!({ "score": {"type": "integer"} }));
461 let page = schema(json!({ "score": {"type": "number"} }));
462 let d = diff_schema(&dest, &page, true);
463 assert_eq!(d.widenings.len(), 1, "got {d:?}");
464 assert_eq!(d.widenings[0].name, "score");
465 assert!(d.incompatible.is_empty());
466 }
467
468 #[test]
469 fn integer_to_number_is_incompatible_when_widening_disallowed() {
470 let dest = schema(json!({ "score": {"type": "integer"} }));
471 let page = schema(json!({ "score": {"type": "number"} }));
472 let d = diff_schema(&dest, &page, false);
473 assert_eq!(d.incompatible.len(), 1, "got {d:?}");
474 assert!(d.widenings.is_empty());
475 }
476
477 #[test]
478 fn gaining_nullability_is_widening() {
479 let dest = schema(json!({ "name": {"type": "string"} }));
480 let page = schema(json!({ "name": {"type": ["string", "null"]} }));
481 let d = diff_schema(&dest, &page, true);
482 assert_eq!(d.widenings.len(), 1, "got {d:?}");
483 }
484
485 #[test]
486 fn string_to_integer_is_incompatible() {
487 let dest = schema(json!({ "id": {"type": "string"} }));
488 let page = schema(json!({ "id": {"type": "integer"} }));
489 let d = diff_schema(&dest, &page, true);
490 assert_eq!(d.incompatible.len(), 1, "got {d:?}");
491 assert!(d.widenings.is_empty());
492 }
493
494 #[test]
495 fn required_destination_column_absent_from_page_is_droppable_required() {
496 let dest = schema(json!({
498 "id": {"type": "integer"},
499 "created_at": {"type": "string"}
500 }));
501 let page = schema(json!({ "id": {"type": "integer"} }));
502 let d = diff_schema(&dest, &page, true);
503 assert_eq!(
504 d.droppable_required,
505 vec!["created_at".to_string()],
506 "got {d:?}"
507 );
508 }
509
510 #[test]
511 fn nullable_destination_column_absent_from_page_is_not_drift() {
512 let dest = schema(json!({
514 "id": {"type": "integer"},
515 "note": {"type": ["string", "null"]}
516 }));
517 let page = schema(json!({ "id": {"type": "integer"} }));
518 let d = diff_schema(&dest, &page, true);
519 assert!(d.is_empty(), "got {d:?}");
520 }
521
522 #[test]
523 fn nested_object_treated_as_single_column() {
524 let dest =
526 schema(json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}}} }));
527 let page = schema(
528 json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "string"}}} }),
529 );
530 let d = diff_schema(&dest, &page, true);
531 assert!(
532 d.is_empty(),
533 "nested changes must not surface as drift; got {d:?}"
534 );
535 }
536
537 #[derive(Debug, PartialEq)]
539 enum Bucket {
540 None,
541 Widening,
542 Incompatible,
543 }
544
545 fn classify_one(dest_ty: Value, page_ty: Value, allow_widening: bool) -> Bucket {
547 let dest = schema(json!({ "col": dest_ty }));
548 let page = schema(json!({ "col": page_ty }));
549 let d = diff_schema(&dest, &page, allow_widening);
550 assert!(d.additions.is_empty(), "unexpected addition: {d:?}");
551 assert!(
552 d.droppable_required.is_empty(),
553 "unexpected droppable: {d:?}"
554 );
555 match (d.widenings.len(), d.incompatible.len()) {
556 (0, 0) => Bucket::None,
557 (1, 0) => Bucket::Widening,
558 (0, 1) => Bucket::Incompatible,
559 _ => panic!("ambiguous classification: {d:?}"),
560 }
561 }
562
563 #[test]
564 fn truth_table_allow_widening() {
565 use Bucket::*;
566 let cases: &[(Value, Value, Bucket)] = &[
568 (json!({"type": "integer"}), json!({"type": "integer"}), None),
569 (json!({"type": "string"}), json!({"type": "string"}), None),
570 (
571 json!({"type": ["string", "null"]}),
572 json!({"type": ["string", "null"]}),
573 None,
574 ),
575 (
577 json!({"type": ["string", "null"]}),
578 json!({"type": "string"}),
579 None,
580 ),
581 (json!({"type": "number"}), json!({"type": "integer"}), None),
583 (
585 json!({"type": "integer"}),
586 json!({"type": "number"}),
587 Widening,
588 ),
589 (
591 json!({"type": "string"}),
592 json!({"type": ["string", "null"]}),
593 Widening,
594 ),
595 (
597 json!({"type": "integer"}),
598 json!({"type": ["number", "null"]}),
599 Widening,
600 ),
601 (
603 json!({"type": ["integer", "null"]}),
604 json!({"type": "number"}),
605 Widening,
606 ),
607 (
609 json!({"type": "string"}),
610 json!({"type": "integer"}),
611 Incompatible,
612 ),
613 (
614 json!({"type": "integer"}),
615 json!({"type": "string"}),
616 Incompatible,
617 ),
618 (
619 json!({"type": "boolean"}),
620 json!({"type": "number"}),
621 Incompatible,
622 ),
623 ];
624 for (dest, page, want) in cases {
625 let got = classify_one(dest.clone(), page.clone(), true);
626 assert_eq!(
627 &got, want,
628 "allow_widening=true: D={dest} P={page} expected {want:?} got {got:?}"
629 );
630 }
631 }
632
633 #[test]
634 fn truth_table_widening_disallowed() {
635 use Bucket::*;
636 let cases: &[(Value, Value, Bucket)] = &[
638 (
640 json!({"type": "integer"}),
641 json!({"type": "number"}),
642 Incompatible,
643 ),
644 (
646 json!({"type": ["string", "null"]}),
647 json!({"type": "string"}),
648 None,
649 ),
650 (
652 json!({"type": "string"}),
653 json!({"type": ["string", "null"]}),
654 Incompatible,
655 ),
656 ];
657 for (dest, page, want) in cases {
658 let got = classify_one(dest.clone(), page.clone(), false);
659 assert_eq!(
660 &got, want,
661 "allow_widening=false: D={dest} P={page} expected {want:?} got {got:?}"
662 );
663 }
664 }
665
666 #[test]
667 fn base_widened_detects_base_type_change() {
668 assert!(base_widened(
670 &json!({"type": "integer"}),
671 &json!({"type": "number"})
672 ));
673 assert!(!base_widened(
675 &json!({"type": "string"}),
676 &json!({"type": ["string", "null"]})
677 ));
678 assert!(!base_widened(
680 &json!({"type": "integer"}),
681 &json!({"type": "integer"})
682 ));
683 assert!(base_widened(
685 &json!({"type": "integer"}),
686 &json!({"type": ["number", "null"]})
687 ));
688 assert!(!base_widened(
690 &json!({"type": "number"}),
691 &json!({"type": "integer"})
692 ));
693 }
694
695 #[test]
696 fn adds_null_detects_nullability_relaxation() {
697 assert!(adds_null(
698 &json!({"type": "string"}),
699 &json!({"type": ["string", "null"]})
700 ));
701 assert!(!adds_null(
703 &json!({"type": ["string", "null"]}),
704 &json!({"type": "string"})
705 ));
706 assert!(!adds_null(
707 &json!({"type": "string"}),
708 &json!({"type": "string"})
709 ));
710 assert!(adds_null(
712 &json!({"type": "integer"}),
713 &json!({"type": ["number", "null"]})
714 ));
715 }
716
717 #[test]
718 fn spec_defaults() {
719 let spec: SchemaDriftSpec = serde_json::from_str("{}").unwrap();
720 assert_eq!(spec.on_drift, OnDrift::Warn);
721 assert!(spec.allow_type_widening);
722 assert_eq!(spec.on_incompatible, OnIncompatible::Fail);
723 }
724
725 #[test]
726 fn on_drift_serializes_snake_case() {
727 assert_eq!(
728 serde_json::to_string(&OnDrift::Evolve).unwrap(),
729 "\"evolve\""
730 );
731 assert_eq!(
732 serde_json::to_string(&OnDrift::Quarantine).unwrap(),
733 "\"quarantine\""
734 );
735 }
736
737 #[test]
738 fn policy_compile_carries_flags() {
739 let spec: SchemaDriftSpec =
740 serde_json::from_str(r#"{"on_drift":"evolve","allow_type_widening":false}"#).unwrap();
741 let policy = SchemaDriftPolicy::compile(&spec);
742 assert_eq!(policy.on_drift, OnDrift::Evolve);
743 assert!(!policy.allow_widening);
744 assert_eq!(policy.on_incompatible, OnIncompatible::Fail);
745 }
746
747 #[test]
748 fn policy_requires_dlq_only_for_quarantine_paths() {
749 let q: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"quarantine"}"#).unwrap();
750 assert!(SchemaDriftPolicy::compile(&q).requires_dlq());
751 let evo_q: SchemaDriftSpec =
752 serde_json::from_str(r#"{"on_drift":"evolve","on_incompatible":"quarantine"}"#)
753 .unwrap();
754 assert!(SchemaDriftPolicy::compile(&evo_q).requires_dlq());
755 let warn: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"warn"}"#).unwrap();
756 assert!(!SchemaDriftPolicy::compile(&warn).requires_dlq());
757 }
758}