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 = also_accepts(dest);
123    // Every page base type must be accepted by the destination.
124    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
131/// Key a sink may add to a column fragment in [`crate::Sink::current_schema`]
132/// listing extra JSON types the column stores faithfully without changing its
133/// declared type — e.g. `{"type":"integer","x-faucet-also-accepts":["boolean"]}`
134/// for a column the sink itself creates for booleans. Such page types fit the
135/// column; they never take part in widening.
136pub 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
145/// `page` without the base types `dest` lists under [`DRIFT_ALSO_ACCEPTS`]
146/// (and does not declare), so they stay out of widening.
147fn 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
173/// Can the destination column losslessly evolve to accept the page?
174///
175/// The merged non-null base family must collapse to a single base type: take
176/// `dest ∪ page`, drop `integer` if `number` is present (int→number collapse),
177/// and require exactly one element. Nullability relaxation is always evolvable,
178/// so only the base-family check gates this.
179fn 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
191/// True when the non-null base type family changes from `from` to `to`
192/// (after the integer→number collapse) — i.e. the column needs an `ALTER TYPE`.
193///
194/// Mirrors the `evolvable` base-family collapse: take `from ∪ to`, drop
195/// `integer` when `number` is present, and treat the change as a base-type
196/// widening only when the resulting family is no longer the destination's
197/// original family. Nullability changes alone never count here.
198pub fn base_widened(from: &Value, to: &Value) -> bool {
199    let (dn, _) = type_set(from);
200    let (pn, _) = type_set(to);
201    // Collapse the destination's own family the same way (so a nullable-only
202    // change yields an identical family and returns false).
203    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
217/// True when `to` permits null but `from` does not — the column needs its
218/// `NOT NULL` constraint relaxed.
219pub 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
225/// Diff a page's inferred shape against the destination schema (top-level columns).
226///
227/// `destination` and `page` are both `infer_schema`-shaped object schemas
228/// (`{"type":"object","properties":{...}}`). `allow_widening` gates whether a
229/// lossless widening lands in `widenings` (true) or `incompatible` (false).
230pub 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; // page values already acceptable — no drift
253                }
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    // Destination columns absent from the page: drift only if NOT NULL.
270    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/// What to do when drift is detected.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
287#[serde(rename_all = "snake_case")]
288pub enum OnDrift {
289    /// Detect + emit a metric and a one-shot log; write the page unchanged.
290    #[default]
291    Warn,
292    /// Apply additive/widening DDL to the destination, then write.
293    Evolve,
294    /// Drop unknown (non-destination) fields from every record; write the rest.
295    Ignore,
296    /// Route the records that exhibit the drift to the DLQ; write the rest.
297    Quarantine,
298    /// Raise `FaucetError::SchemaDrift` and abort.
299    Fail,
300}
301
302/// `evolve`-only: what to do with a narrowing/incompatible change that can't be
303/// auto-applied.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
305#[serde(rename_all = "snake_case")]
306pub enum OnIncompatible {
307    /// Abort the run (default).
308    #[default]
309    Fail,
310    /// Route the offending records to the DLQ.
311    Quarantine,
312}
313
314fn default_true() -> bool {
315    true
316}
317
318/// User-facing `schema:` config block (pipeline level).
319#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
320#[serde(deny_unknown_fields)]
321pub struct SchemaDriftSpec {
322    /// Policy applied when drift is detected.
323    #[serde(default)]
324    pub on_drift: OnDrift,
325    /// Whether a lossless widening counts as evolvable (vs incompatible).
326    /// Only consulted by `evolve`. Default: true.
327    #[serde(default = "default_true")]
328    pub allow_type_widening: bool,
329    /// `evolve` only: action for an incompatible residue. Default: fail.
330    #[serde(default)]
331    pub on_incompatible: OnIncompatible,
332    /// `evolve` only: whether the **absence** of a destination `NOT NULL`
333    /// column from a page may drop that column's `NOT NULL` constraint.
334    ///
335    /// Default `false`: a column merely omitted from one batch is *not*
336    /// evidence the column is genuinely optional (a transient/partial page
337    /// omits it just as readily as a real schema change), so auto-relaxing
338    /// would silently and irreversibly weaken the destination's integrity
339    /// (issue #194 / F28). When `false`, an omitted required column is left
340    /// untouched — a page that genuinely lacks a required value then fails
341    /// loudly at write time rather than degrading the schema. Set `true` only
342    /// when you deliberately want missing-column omission to relax the
343    /// constraint. Nullability relaxation driven by an *observed* null value
344    /// in a present column (a widening that adds `null`) is unaffected by this
345    /// flag — that is evidence-based, not omission-based.
346    #[serde(default)]
347    pub relax_nullability_on_missing: bool,
348}
349
350/// Compiled, ready-to-run drift policy. Cheap to clone.
351#[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    /// See [`SchemaDriftSpec::relax_nullability_on_missing`]. Default `false`.
357    pub relax_nullability_on_missing: bool,
358}
359
360impl SchemaDriftPolicy {
361    /// Compile a spec into a runnable policy. Infallible — there is nothing to
362    /// validate that serde hasn't already (the DLQ requirement is enforced by
363    /// the pipeline at run start and by the CLI at config-load).
364    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    /// `true` when this policy can route records to a DLQ (so one must exist).
374    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/// Backend-neutral base column type inferred from a JSON-Schema fragment.
382/// Each SQL sink maps these to its concrete keyword.
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum SqlBaseType {
385    Integer,
386    Double,
387    Boolean,
388    Text,
389    /// Nested object / array → stored as JSON/JSONB/NVARCHAR(MAX) text.
390    Json,
391}
392
393/// Map a top-level JSON-Schema type fragment to a base SQL type, or `None` for
394/// a pure-null fragment (caller falls back to TEXT/NVARCHAR for an added column
395/// whose only observed value was null).
396pub fn json_schema_base_type(fragment: &Value) -> Option<SqlBaseType> {
397    let (names, _nullable) = type_set(fragment);
398    // Prefer the widest informative type among the union.
399    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        // Destination has a non-nullable `created_at` the page never provides.
547        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        // A column the destination already allows to be null is fine to omit.
563        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        // A change *inside* a nested object is invisible — top-level only.
575        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    /// Which bucket a single-column diff landed in.
588    #[derive(Debug, PartialEq)]
589    enum Bucket {
590        None,
591        Widening,
592        Incompatible,
593    }
594
595    /// Diff a single column `col` (D vs P) and report which bucket it landed in.
596    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        // (dest, page, expected bucket) — allow_widening = true.
617        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            // Regression guard: non-null page fits a nullable dest.
626            (
627                json!({"type": ["string", "null"]}),
628                json!({"type": "string"}),
629                None,
630            ),
631            // Regression guard: integer fits a number column.
632            (json!({"type": "number"}), json!({"type": "integer"}), None),
633            // integer → number widening.
634            (
635                json!({"type": "integer"}),
636                json!({"type": "number"}),
637                Widening,
638            ),
639            // string → nullable string (relax null).
640            (
641                json!({"type": "string"}),
642                json!({"type": ["string", "null"]}),
643                Widening,
644            ),
645            // integer → nullable number (int→number + null relax).
646            (
647                json!({"type": "integer"}),
648                json!({"type": ["number", "null"]}),
649                Widening,
650            ),
651            // nullable integer dest, number page → int→number, dest already nullable.
652            (
653                json!({"type": ["integer", "null"]}),
654                json!({"type": "number"}),
655                Widening,
656            ),
657            // Genuine incompatibilities.
658            (
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        // (dest, page, expected bucket) — allow_widening = false.
687        let cases: &[(Value, Value, Bucket)] = &[
688            // int→number is no longer a widening; with widening off it is incompatible.
689            (
690                json!({"type": "integer"}),
691                json!({"type": "number"}),
692                Incompatible,
693            ),
694            // `fits` still applies regardless of allow_widening.
695            (
696                json!({"type": ["string", "null"]}),
697                json!({"type": "string"}),
698                None,
699            ),
700            // null relaxation is a widening, not a fit → incompatible when disallowed.
701            (
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        // integer → number is a base-type widening (needs ALTER TYPE).
719        assert!(base_widened(
720            &json!({"type": "integer"}),
721            &json!({"type": "number"})
722        ));
723        // nullability-only relaxation is NOT a base-type widening.
724        assert!(!base_widened(
725            &json!({"type": "string"}),
726            &json!({"type": ["string", "null"]})
727        ));
728        // identical base type, no change.
729        assert!(!base_widened(
730            &json!({"type": "integer"}),
731            &json!({"type": "integer"})
732        ));
733        // integer dest, nullable number page → base family changed.
734        assert!(base_widened(
735            &json!({"type": "integer"}),
736            &json!({"type": ["number", "null"]})
737        ));
738        // number dest, integer page → integer collapses into number, no change.
739        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        // already nullable destination → not adding null.
752        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        // page nullable, dest not → adds null even with a base change.
761        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}