Skip to main content

faucet_core/
drift.rs

1//! Schema-drift detection + policy types (issue #194).
2//!
3//! Drift is the divergence between an incoming page's inferred top-level shape
4//! (via [`crate::schema::infer_schema`]) and the sink's live destination schema
5//! (via [`crate::Sink::current_schema`]). The pure [`diff_schema`] classifies
6//! each top-level column into one bucket; [`SchemaDriftPolicy`] decides what the
7//! pipeline does with the result. Nested objects are treated as a single column.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// One column's drift, expressed in JSON-Schema type-fragment terms
14/// (e.g. `{"type":"integer"}` or `{"type":["string","null"]}`).
15#[derive(Debug, Clone, PartialEq)]
16pub struct ColumnChange {
17    /// Top-level column name.
18    pub name: String,
19    /// Destination type fragment; `None` for an addition (not in destination).
20    pub from: Option<Value>,
21    /// Inferred type fragment from the incoming page.
22    pub to: Value,
23}
24
25/// Result of diffing a page's inferred shape against the destination schema.
26#[derive(Debug, Clone, Default, PartialEq)]
27pub struct SchemaDiff {
28    /// In the page, not in the destination.
29    pub additions: Vec<ColumnChange>,
30    /// Existing column whose type widened losslessly (e.g. integer→number,
31    /// or gained nullability).
32    pub widenings: Vec<ColumnChange>,
33    /// Existing column whose type changed in a way that cannot be auto-applied
34    /// (narrowing / incompatible type swap).
35    pub incompatible: Vec<ColumnChange>,
36    /// In the destination and NOT NULL, absent from the page — would fail an
37    /// insert unless relaxed to nullable.
38    pub droppable_required: Vec<String>,
39}
40
41impl SchemaDiff {
42    /// `true` when no drift of any kind was detected.
43    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    /// Column names that drifted, for error messages / metrics.
51    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/// The applyable subset of a [`SchemaDiff`] handed to [`crate::Sink::evolve_schema`].
63/// Never carries `incompatible` columns — those are routed by the policy.
64#[derive(Debug, Clone, Default, PartialEq)]
65pub struct SchemaEvolution {
66    pub additions: Vec<ColumnChange>,
67    pub widenings: Vec<ColumnChange>,
68    /// Columns to relax from NOT NULL to nullable.
69    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
78/// The set of JSON-Schema primitive type names a fragment carries (excluding `null`),
79/// plus whether `null` is present.
80fn 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
108/// Are the page's values already acceptable as-is by the destination?
109///
110/// True iff the page introduces no nulls a non-nullable destination would
111/// reject, AND every non-null base type the page carries is accepted by the
112/// destination (an exact base-type match, or `integer` landing in a `number`
113/// column). `fits` is never gated by `allow_widening` — a fitting page is not
114/// drift at all.
115fn fits(dest: &Value, page: &Value) -> bool {
116    let (dn, dnull) = type_set(dest);
117    let (pn, pnull) = type_set(page);
118    // The page must not introduce nulls a non-nullable destination rejects.
119    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    // Every page base type must be accepted by the destination.
128    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
135/// Key a sink may add to a column fragment in [`crate::Sink::current_schema`]
136/// listing extra JSON types the column stores faithfully without changing its
137/// declared type — e.g. `{"type":"integer","x-faucet-also-accepts":["boolean"]}`
138/// for a column the sink itself creates for booleans. Such page types fit the
139/// column; they never take part in widening.
140pub const DRIFT_ALSO_ACCEPTS: &str = "x-faucet-also-accepts";
141
142/// Can the destination column losslessly evolve to accept the page?
143///
144/// The merged non-null base family must collapse to a single base type: take
145/// `dest ∪ page`, drop `integer` if `number` is present (int→number collapse),
146/// and require exactly one element. Nullability relaxation is always evolvable,
147/// so only the base-family check gates this.
148fn 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
160/// True when the non-null base type family changes from `from` to `to`
161/// (after the integer→number collapse) — i.e. the column needs an `ALTER TYPE`.
162///
163/// Mirrors the `evolvable` base-family collapse: take `from ∪ to`, drop
164/// `integer` when `number` is present, and treat the change as a base-type
165/// widening only when the resulting family is no longer the destination's
166/// original family. Nullability changes alone never count here.
167pub fn base_widened(from: &Value, to: &Value) -> bool {
168    let (dn, _) = type_set(from);
169    let (pn, _) = type_set(to);
170    // Collapse the destination's own family the same way (so a nullable-only
171    // change yields an identical family and returns false).
172    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
186/// True when `to` permits null but `from` does not — the column needs its
187/// `NOT NULL` constraint relaxed.
188pub 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
194/// Diff a page's inferred shape against the destination schema (top-level columns).
195///
196/// `destination` and `page` are both `infer_schema`-shaped object schemas
197/// (`{"type":"object","properties":{...}}`). `allow_widening` gates whether a
198/// lossless widening lands in `widenings` (true) or `incompatible` (false).
199pub 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; // page values already acceptable — no drift
222                }
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    // Destination columns absent from the page: drift only if NOT NULL.
238    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/// What to do when drift is detected.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
255#[serde(rename_all = "snake_case")]
256pub enum OnDrift {
257    /// Detect + emit a metric and a one-shot log; write the page unchanged.
258    #[default]
259    Warn,
260    /// Apply additive/widening DDL to the destination, then write.
261    Evolve,
262    /// Drop unknown (non-destination) fields from every record; write the rest.
263    Ignore,
264    /// Route the records that exhibit the drift to the DLQ; write the rest.
265    Quarantine,
266    /// Raise `FaucetError::SchemaDrift` and abort.
267    Fail,
268}
269
270/// `evolve`-only: what to do with a narrowing/incompatible change that can't be
271/// auto-applied.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
273#[serde(rename_all = "snake_case")]
274pub enum OnIncompatible {
275    /// Abort the run (default).
276    #[default]
277    Fail,
278    /// Route the offending records to the DLQ.
279    Quarantine,
280}
281
282fn default_true() -> bool {
283    true
284}
285
286/// User-facing `schema:` config block (pipeline level).
287#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
288#[serde(deny_unknown_fields)]
289pub struct SchemaDriftSpec {
290    /// Policy applied when drift is detected.
291    #[serde(default)]
292    pub on_drift: OnDrift,
293    /// Whether a lossless widening counts as evolvable (vs incompatible).
294    /// Only consulted by `evolve`. Default: true.
295    #[serde(default = "default_true")]
296    pub allow_type_widening: bool,
297    /// `evolve` only: action for an incompatible residue. Default: fail.
298    #[serde(default)]
299    pub on_incompatible: OnIncompatible,
300    /// `evolve` only: whether the **absence** of a destination `NOT NULL`
301    /// column from a page may drop that column's `NOT NULL` constraint.
302    ///
303    /// Default `false`: a column merely omitted from one batch is *not*
304    /// evidence the column is genuinely optional (a transient/partial page
305    /// omits it just as readily as a real schema change), so auto-relaxing
306    /// would silently and irreversibly weaken the destination's integrity
307    /// (issue #194 / F28). When `false`, an omitted required column is left
308    /// untouched — a page that genuinely lacks a required value then fails
309    /// loudly at write time rather than degrading the schema. Set `true` only
310    /// when you deliberately want missing-column omission to relax the
311    /// constraint. Nullability relaxation driven by an *observed* null value
312    /// in a present column (a widening that adds `null`) is unaffected by this
313    /// flag — that is evidence-based, not omission-based.
314    #[serde(default)]
315    pub relax_nullability_on_missing: bool,
316}
317
318/// Compiled, ready-to-run drift policy. Cheap to clone.
319#[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    /// See [`SchemaDriftSpec::relax_nullability_on_missing`]. Default `false`.
325    pub relax_nullability_on_missing: bool,
326}
327
328impl SchemaDriftPolicy {
329    /// Compile a spec into a runnable policy. Infallible — there is nothing to
330    /// validate that serde hasn't already (the DLQ requirement is enforced by
331    /// the pipeline at run start and by the CLI at config-load).
332    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    /// `true` when this policy can route records to a DLQ (so one must exist).
342    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/// Backend-neutral base column type inferred from a JSON-Schema fragment.
350/// Each SQL sink maps these to its concrete keyword.
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum SqlBaseType {
353    Integer,
354    Double,
355    Boolean,
356    Text,
357    /// Nested object / array → stored as JSON/JSONB/NVARCHAR(MAX) text.
358    Json,
359}
360
361/// Map a top-level JSON-Schema type fragment to a base SQL type, or `None` for
362/// a pure-null fragment (caller falls back to TEXT/NVARCHAR for an added column
363/// whose only observed value was null).
364pub fn json_schema_base_type(fragment: &Value) -> Option<SqlBaseType> {
365    let (names, _nullable) = type_set(fragment);
366    // Prefer the widest informative type among the union.
367    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        // Destination has a non-nullable `created_at` the page never provides.
497        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        // A column the destination already allows to be null is fine to omit.
513        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        // A change *inside* a nested object is invisible — top-level only.
525        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    /// Which bucket a single-column diff landed in.
538    #[derive(Debug, PartialEq)]
539    enum Bucket {
540        None,
541        Widening,
542        Incompatible,
543    }
544
545    /// Diff a single column `col` (D vs P) and report which bucket it landed in.
546    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        // (dest, page, expected bucket) — allow_widening = true.
567        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            // Regression guard: non-null page fits a nullable dest.
576            (
577                json!({"type": ["string", "null"]}),
578                json!({"type": "string"}),
579                None,
580            ),
581            // Regression guard: integer fits a number column.
582            (json!({"type": "number"}), json!({"type": "integer"}), None),
583            // integer → number widening.
584            (
585                json!({"type": "integer"}),
586                json!({"type": "number"}),
587                Widening,
588            ),
589            // string → nullable string (relax null).
590            (
591                json!({"type": "string"}),
592                json!({"type": ["string", "null"]}),
593                Widening,
594            ),
595            // integer → nullable number (int→number + null relax).
596            (
597                json!({"type": "integer"}),
598                json!({"type": ["number", "null"]}),
599                Widening,
600            ),
601            // nullable integer dest, number page → int→number, dest already nullable.
602            (
603                json!({"type": ["integer", "null"]}),
604                json!({"type": "number"}),
605                Widening,
606            ),
607            // Genuine incompatibilities.
608            (
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        // (dest, page, expected bucket) — allow_widening = false.
637        let cases: &[(Value, Value, Bucket)] = &[
638            // int→number is no longer a widening; with widening off it is incompatible.
639            (
640                json!({"type": "integer"}),
641                json!({"type": "number"}),
642                Incompatible,
643            ),
644            // `fits` still applies regardless of allow_widening.
645            (
646                json!({"type": ["string", "null"]}),
647                json!({"type": "string"}),
648                None,
649            ),
650            // null relaxation is a widening, not a fit → incompatible when disallowed.
651            (
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        // integer → number is a base-type widening (needs ALTER TYPE).
669        assert!(base_widened(
670            &json!({"type": "integer"}),
671            &json!({"type": "number"})
672        ));
673        // nullability-only relaxation is NOT a base-type widening.
674        assert!(!base_widened(
675            &json!({"type": "string"}),
676            &json!({"type": ["string", "null"]})
677        ));
678        // identical base type, no change.
679        assert!(!base_widened(
680            &json!({"type": "integer"}),
681            &json!({"type": "integer"})
682        ));
683        // integer dest, nullable number page → base family changed.
684        assert!(base_widened(
685            &json!({"type": "integer"}),
686            &json!({"type": ["number", "null"]})
687        ));
688        // number dest, integer page → integer collapses into number, no change.
689        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        // already nullable destination → not adding null.
702        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        // page nullable, dest not → adds null even with a base change.
711        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}