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 pn.iter()
124 .all(|t| dn.contains(t) || (t == "integer" && dn.iter().any(|d| d == "number")))
125}
126
127fn evolvable(dest: &Value, page: &Value) -> bool {
134 let (dn, _) = type_set(dest);
135 let (pn, _) = type_set(page);
136 let mut merged: Vec<String> = dn.into_iter().chain(pn).collect();
137 merged.sort();
138 merged.dedup();
139 if merged.iter().any(|t| t == "number") {
140 merged.retain(|t| t != "integer");
141 }
142 merged.len() == 1
143}
144
145pub fn base_widened(from: &Value, to: &Value) -> bool {
153 let (dn, _) = type_set(from);
154 let (pn, _) = type_set(to);
155 let collapse = |names: Vec<String>| -> Vec<String> {
158 let mut m: Vec<String> = names;
159 m.sort();
160 m.dedup();
161 if m.iter().any(|t| t == "number") {
162 m.retain(|t| t != "integer");
163 }
164 m
165 };
166 let dest_family = collapse(dn.clone());
167 let merged = collapse(dn.into_iter().chain(pn).collect());
168 merged != dest_family
169}
170
171pub fn adds_null(from: &Value, to: &Value) -> bool {
174 let (_, fnull) = type_set(from);
175 let (_, tnull) = type_set(to);
176 tnull && !fnull
177}
178
179pub fn diff_schema(destination: &Value, page: &Value, allow_widening: bool) -> SchemaDiff {
185 let empty = serde_json::Map::new();
186 let dest_props = destination
187 .get("properties")
188 .and_then(|p| p.as_object())
189 .unwrap_or(&empty);
190 let page_props = page
191 .get("properties")
192 .and_then(|p| p.as_object())
193 .unwrap_or(&empty);
194
195 let mut diff = SchemaDiff::default();
196
197 for (name, page_ty) in page_props {
198 match dest_props.get(name) {
199 None => diff.additions.push(ColumnChange {
200 name: name.clone(),
201 from: None,
202 to: page_ty.clone(),
203 }),
204 Some(dest_ty) => {
205 if fits(dest_ty, page_ty) {
206 continue; }
208 let change = ColumnChange {
209 name: name.clone(),
210 from: Some(dest_ty.clone()),
211 to: page_ty.clone(),
212 };
213 if allow_widening && evolvable(dest_ty, page_ty) {
214 diff.widenings.push(change);
215 } else {
216 diff.incompatible.push(change);
217 }
218 }
219 }
220 }
221
222 for (name, dest_ty) in dest_props {
224 if !page_props.contains_key(name) {
225 let (_, nullable) = type_set(dest_ty);
226 if !nullable {
227 diff.droppable_required.push(name.clone());
228 }
229 }
230 }
231 diff.additions.sort_by(|a, b| a.name.cmp(&b.name));
232 diff.widenings.sort_by(|a, b| a.name.cmp(&b.name));
233 diff.incompatible.sort_by(|a, b| a.name.cmp(&b.name));
234 diff.droppable_required.sort();
235 diff
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
240#[serde(rename_all = "snake_case")]
241pub enum OnDrift {
242 #[default]
244 Warn,
245 Evolve,
247 Ignore,
249 Quarantine,
251 Fail,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
258#[serde(rename_all = "snake_case")]
259pub enum OnIncompatible {
260 #[default]
262 Fail,
263 Quarantine,
265}
266
267fn default_true() -> bool {
268 true
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
273#[serde(deny_unknown_fields)]
274pub struct SchemaDriftSpec {
275 #[serde(default)]
277 pub on_drift: OnDrift,
278 #[serde(default = "default_true")]
281 pub allow_type_widening: bool,
282 #[serde(default)]
284 pub on_incompatible: OnIncompatible,
285 #[serde(default)]
300 pub relax_nullability_on_missing: bool,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub struct SchemaDriftPolicy {
306 pub on_drift: OnDrift,
307 pub allow_widening: bool,
308 pub on_incompatible: OnIncompatible,
309 pub relax_nullability_on_missing: bool,
311}
312
313impl SchemaDriftPolicy {
314 pub fn compile(spec: &SchemaDriftSpec) -> Self {
318 Self {
319 on_drift: spec.on_drift,
320 allow_widening: spec.allow_type_widening,
321 on_incompatible: spec.on_incompatible,
322 relax_nullability_on_missing: spec.relax_nullability_on_missing,
323 }
324 }
325
326 pub fn requires_dlq(&self) -> bool {
328 self.on_drift == OnDrift::Quarantine
329 || (self.on_drift == OnDrift::Evolve
330 && self.on_incompatible == OnIncompatible::Quarantine)
331 }
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub enum SqlBaseType {
338 Integer,
339 Double,
340 Boolean,
341 Text,
342 Json,
344}
345
346pub fn json_schema_base_type(fragment: &Value) -> Option<SqlBaseType> {
350 let (names, _nullable) = type_set(fragment);
351 if names.iter().any(|t| t == "object" || t == "array") {
353 return Some(SqlBaseType::Json);
354 }
355 if names.iter().any(|t| t == "string") {
356 return Some(SqlBaseType::Text);
357 }
358 if names.iter().any(|t| t == "number") {
359 return Some(SqlBaseType::Double);
360 }
361 if names.iter().any(|t| t == "integer") {
362 return Some(SqlBaseType::Integer);
363 }
364 if names.iter().any(|t| t == "boolean") {
365 return Some(SqlBaseType::Boolean);
366 }
367 None
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use serde_json::json;
374
375 fn schema(props: Value) -> Value {
376 json!({ "type": "object", "properties": props })
377 }
378
379 #[test]
380 fn sql_type_mapping() {
381 use super::SqlBaseType::*;
382 assert_eq!(
383 json_schema_base_type(&json!({"type":"integer"})),
384 Some(Integer)
385 );
386 assert_eq!(
387 json_schema_base_type(&json!({"type":"number"})),
388 Some(Double)
389 );
390 assert_eq!(
391 json_schema_base_type(&json!({"type":"boolean"})),
392 Some(Boolean)
393 );
394 assert_eq!(json_schema_base_type(&json!({"type":"string"})), Some(Text));
395 assert_eq!(
396 json_schema_base_type(&json!({"type":["string","null"]})),
397 Some(Text)
398 );
399 assert_eq!(json_schema_base_type(&json!({"type":"object"})), Some(Json));
400 assert_eq!(json_schema_base_type(&json!({"type":"array"})), Some(Json));
401 assert_eq!(json_schema_base_type(&json!({"type":"null"})), None);
402 }
403
404 #[test]
405 fn no_drift_when_shapes_match() {
406 let dest = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
407 let page = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
408 let d = diff_schema(&dest, &page, true);
409 assert!(d.is_empty(), "got {d:?}");
410 }
411
412 #[test]
413 fn detects_addition() {
414 let dest = schema(json!({ "id": {"type": "integer"} }));
415 let page = schema(json!({ "id": {"type": "integer"}, "email": {"type": "string"} }));
416 let d = diff_schema(&dest, &page, true);
417 assert_eq!(d.additions.len(), 1);
418 assert_eq!(d.additions[0].name, "email");
419 assert!(d.additions[0].from.is_none());
420 assert_eq!(d.additions[0].to, json!({"type": "string"}));
421 assert!(d.widenings.is_empty() && d.incompatible.is_empty());
422 }
423
424 #[test]
425 fn integer_to_number_is_widening_when_allowed() {
426 let dest = schema(json!({ "score": {"type": "integer"} }));
427 let page = schema(json!({ "score": {"type": "number"} }));
428 let d = diff_schema(&dest, &page, true);
429 assert_eq!(d.widenings.len(), 1, "got {d:?}");
430 assert_eq!(d.widenings[0].name, "score");
431 assert!(d.incompatible.is_empty());
432 }
433
434 #[test]
435 fn integer_to_number_is_incompatible_when_widening_disallowed() {
436 let dest = schema(json!({ "score": {"type": "integer"} }));
437 let page = schema(json!({ "score": {"type": "number"} }));
438 let d = diff_schema(&dest, &page, false);
439 assert_eq!(d.incompatible.len(), 1, "got {d:?}");
440 assert!(d.widenings.is_empty());
441 }
442
443 #[test]
444 fn gaining_nullability_is_widening() {
445 let dest = schema(json!({ "name": {"type": "string"} }));
446 let page = schema(json!({ "name": {"type": ["string", "null"]} }));
447 let d = diff_schema(&dest, &page, true);
448 assert_eq!(d.widenings.len(), 1, "got {d:?}");
449 }
450
451 #[test]
452 fn string_to_integer_is_incompatible() {
453 let dest = schema(json!({ "id": {"type": "string"} }));
454 let page = schema(json!({ "id": {"type": "integer"} }));
455 let d = diff_schema(&dest, &page, true);
456 assert_eq!(d.incompatible.len(), 1, "got {d:?}");
457 assert!(d.widenings.is_empty());
458 }
459
460 #[test]
461 fn required_destination_column_absent_from_page_is_droppable_required() {
462 let dest = schema(json!({
464 "id": {"type": "integer"},
465 "created_at": {"type": "string"}
466 }));
467 let page = schema(json!({ "id": {"type": "integer"} }));
468 let d = diff_schema(&dest, &page, true);
469 assert_eq!(
470 d.droppable_required,
471 vec!["created_at".to_string()],
472 "got {d:?}"
473 );
474 }
475
476 #[test]
477 fn nullable_destination_column_absent_from_page_is_not_drift() {
478 let dest = schema(json!({
480 "id": {"type": "integer"},
481 "note": {"type": ["string", "null"]}
482 }));
483 let page = schema(json!({ "id": {"type": "integer"} }));
484 let d = diff_schema(&dest, &page, true);
485 assert!(d.is_empty(), "got {d:?}");
486 }
487
488 #[test]
489 fn nested_object_treated_as_single_column() {
490 let dest =
492 schema(json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}}} }));
493 let page = schema(
494 json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "string"}}} }),
495 );
496 let d = diff_schema(&dest, &page, true);
497 assert!(
498 d.is_empty(),
499 "nested changes must not surface as drift; got {d:?}"
500 );
501 }
502
503 #[derive(Debug, PartialEq)]
505 enum Bucket {
506 None,
507 Widening,
508 Incompatible,
509 }
510
511 fn classify_one(dest_ty: Value, page_ty: Value, allow_widening: bool) -> Bucket {
513 let dest = schema(json!({ "col": dest_ty }));
514 let page = schema(json!({ "col": page_ty }));
515 let d = diff_schema(&dest, &page, allow_widening);
516 assert!(d.additions.is_empty(), "unexpected addition: {d:?}");
517 assert!(
518 d.droppable_required.is_empty(),
519 "unexpected droppable: {d:?}"
520 );
521 match (d.widenings.len(), d.incompatible.len()) {
522 (0, 0) => Bucket::None,
523 (1, 0) => Bucket::Widening,
524 (0, 1) => Bucket::Incompatible,
525 _ => panic!("ambiguous classification: {d:?}"),
526 }
527 }
528
529 #[test]
530 fn truth_table_allow_widening() {
531 use Bucket::*;
532 let cases: &[(Value, Value, Bucket)] = &[
534 (json!({"type": "integer"}), json!({"type": "integer"}), None),
535 (json!({"type": "string"}), json!({"type": "string"}), None),
536 (
537 json!({"type": ["string", "null"]}),
538 json!({"type": ["string", "null"]}),
539 None,
540 ),
541 (
543 json!({"type": ["string", "null"]}),
544 json!({"type": "string"}),
545 None,
546 ),
547 (json!({"type": "number"}), json!({"type": "integer"}), None),
549 (
551 json!({"type": "integer"}),
552 json!({"type": "number"}),
553 Widening,
554 ),
555 (
557 json!({"type": "string"}),
558 json!({"type": ["string", "null"]}),
559 Widening,
560 ),
561 (
563 json!({"type": "integer"}),
564 json!({"type": ["number", "null"]}),
565 Widening,
566 ),
567 (
569 json!({"type": ["integer", "null"]}),
570 json!({"type": "number"}),
571 Widening,
572 ),
573 (
575 json!({"type": "string"}),
576 json!({"type": "integer"}),
577 Incompatible,
578 ),
579 (
580 json!({"type": "integer"}),
581 json!({"type": "string"}),
582 Incompatible,
583 ),
584 (
585 json!({"type": "boolean"}),
586 json!({"type": "number"}),
587 Incompatible,
588 ),
589 ];
590 for (dest, page, want) in cases {
591 let got = classify_one(dest.clone(), page.clone(), true);
592 assert_eq!(
593 &got, want,
594 "allow_widening=true: D={dest} P={page} expected {want:?} got {got:?}"
595 );
596 }
597 }
598
599 #[test]
600 fn truth_table_widening_disallowed() {
601 use Bucket::*;
602 let cases: &[(Value, Value, Bucket)] = &[
604 (
606 json!({"type": "integer"}),
607 json!({"type": "number"}),
608 Incompatible,
609 ),
610 (
612 json!({"type": ["string", "null"]}),
613 json!({"type": "string"}),
614 None,
615 ),
616 (
618 json!({"type": "string"}),
619 json!({"type": ["string", "null"]}),
620 Incompatible,
621 ),
622 ];
623 for (dest, page, want) in cases {
624 let got = classify_one(dest.clone(), page.clone(), false);
625 assert_eq!(
626 &got, want,
627 "allow_widening=false: D={dest} P={page} expected {want:?} got {got:?}"
628 );
629 }
630 }
631
632 #[test]
633 fn base_widened_detects_base_type_change() {
634 assert!(base_widened(
636 &json!({"type": "integer"}),
637 &json!({"type": "number"})
638 ));
639 assert!(!base_widened(
641 &json!({"type": "string"}),
642 &json!({"type": ["string", "null"]})
643 ));
644 assert!(!base_widened(
646 &json!({"type": "integer"}),
647 &json!({"type": "integer"})
648 ));
649 assert!(base_widened(
651 &json!({"type": "integer"}),
652 &json!({"type": ["number", "null"]})
653 ));
654 assert!(!base_widened(
656 &json!({"type": "number"}),
657 &json!({"type": "integer"})
658 ));
659 }
660
661 #[test]
662 fn adds_null_detects_nullability_relaxation() {
663 assert!(adds_null(
664 &json!({"type": "string"}),
665 &json!({"type": ["string", "null"]})
666 ));
667 assert!(!adds_null(
669 &json!({"type": ["string", "null"]}),
670 &json!({"type": "string"})
671 ));
672 assert!(!adds_null(
673 &json!({"type": "string"}),
674 &json!({"type": "string"})
675 ));
676 assert!(adds_null(
678 &json!({"type": "integer"}),
679 &json!({"type": ["number", "null"]})
680 ));
681 }
682
683 #[test]
684 fn spec_defaults() {
685 let spec: SchemaDriftSpec = serde_json::from_str("{}").unwrap();
686 assert_eq!(spec.on_drift, OnDrift::Warn);
687 assert!(spec.allow_type_widening);
688 assert_eq!(spec.on_incompatible, OnIncompatible::Fail);
689 }
690
691 #[test]
692 fn on_drift_serializes_snake_case() {
693 assert_eq!(
694 serde_json::to_string(&OnDrift::Evolve).unwrap(),
695 "\"evolve\""
696 );
697 assert_eq!(
698 serde_json::to_string(&OnDrift::Quarantine).unwrap(),
699 "\"quarantine\""
700 );
701 }
702
703 #[test]
704 fn policy_compile_carries_flags() {
705 let spec: SchemaDriftSpec =
706 serde_json::from_str(r#"{"on_drift":"evolve","allow_type_widening":false}"#).unwrap();
707 let policy = SchemaDriftPolicy::compile(&spec);
708 assert_eq!(policy.on_drift, OnDrift::Evolve);
709 assert!(!policy.allow_widening);
710 assert_eq!(policy.on_incompatible, OnIncompatible::Fail);
711 }
712
713 #[test]
714 fn policy_requires_dlq_only_for_quarantine_paths() {
715 let q: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"quarantine"}"#).unwrap();
716 assert!(SchemaDriftPolicy::compile(&q).requires_dlq());
717 let evo_q: SchemaDriftSpec =
718 serde_json::from_str(r#"{"on_drift":"evolve","on_incompatible":"quarantine"}"#)
719 .unwrap();
720 assert!(SchemaDriftPolicy::compile(&evo_q).requires_dlq());
721 let warn: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"warn"}"#).unwrap();
722 assert!(!SchemaDriftPolicy::compile(&warn).requires_dlq());
723 }
724}