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/// Whether one record's own `value` for a column fits the destination column
132/// type `dest` — the per-row test behind a column a page-level diff reported
133/// as widened or incompatible, so a row whose value already fits is kept.
134pub(crate) fn value_fits(dest: &Value, name: &str, value: &Value) -> bool {
135    let destination = serde_json::json!({"type": "object", "properties": {name: dest}});
136    let mut row = serde_json::Map::new();
137    row.insert(name.to_string(), value.clone());
138    let page = infer_page_schema(&destination, &[Value::Object(row)]);
139    page.get("properties")
140        .and_then(|p| p.get(name))
141        .is_some_and(|fragment| fits(dest, fragment))
142}
143
144/// Key a sink may add to a column fragment in [`crate::Sink::current_schema`]
145/// listing extra JSON types the column stores faithfully without changing its
146/// declared type — e.g. `{"type":"integer","x-faucet-also-accepts":["boolean"]}`
147/// for a column the sink itself creates for booleans. Such page types fit the
148/// column; they never take part in widening.
149pub const DRIFT_ALSO_ACCEPTS: &str = "x-faucet-also-accepts";
150
151fn also_accepts(dest: &Value) -> Vec<&str> {
152    dest.get(DRIFT_ALSO_ACCEPTS)
153        .and_then(Value::as_array)
154        .map(|a| a.iter().filter_map(Value::as_str).collect())
155        .unwrap_or_default()
156}
157
158/// `page` without the base types `dest` lists under [`DRIFT_ALSO_ACCEPTS`]
159/// (and does not declare), so they stay out of widening.
160fn without_also_accepted(dest: &Value, page: &Value) -> Value {
161    let also = also_accepts(dest);
162    if also.is_empty() {
163        return page.clone();
164    }
165    let (dn, _) = type_set(dest);
166    let (pn, pnull) = type_set(page);
167    let mut kept: Vec<Value> = pn
168        .into_iter()
169        .filter(|t| dn.contains(t) || !also.contains(&t.as_str()))
170        .map(Value::String)
171        .collect();
172    if pnull {
173        kept.push(Value::String("null".into()));
174    }
175    let mut out = page.clone();
176    if let Some(obj) = out.as_object_mut() {
177        let ty = match kept.len() {
178            1 => kept.remove(0),
179            _ => Value::Array(kept),
180        };
181        obj.insert("type".into(), ty);
182    }
183    out
184}
185
186/// Can the destination column losslessly evolve to accept the page?
187///
188/// The merged non-null base family must collapse to a single base type: take
189/// `dest ∪ page`, drop `integer` if `number` is present (int→number collapse),
190/// and require exactly one element. Nullability relaxation is always evolvable,
191/// so only the base-family check gates this.
192fn evolvable(dest: &Value, page: &Value) -> bool {
193    let (dn, _) = type_set(dest);
194    let (pn, _) = type_set(page);
195    let mut merged: Vec<String> = dn.into_iter().chain(pn).collect();
196    merged.sort();
197    merged.dedup();
198    if merged.iter().any(|t| t == "number") {
199        merged.retain(|t| t != "integer");
200    }
201    merged.len() == 1
202}
203
204/// True when the non-null base type family changes from `from` to `to`
205/// (after the integer→number collapse) — i.e. the column needs an `ALTER TYPE`.
206///
207/// Mirrors the `evolvable` base-family collapse: take `from ∪ to`, drop
208/// `integer` when `number` is present, and treat the change as a base-type
209/// widening only when the resulting family is no longer the destination's
210/// original family. Nullability changes alone never count here.
211pub fn base_widened(from: &Value, to: &Value) -> bool {
212    let (dn, _) = type_set(from);
213    let (pn, _) = type_set(to);
214    // Collapse the destination's own family the same way (so a nullable-only
215    // change yields an identical family and returns false).
216    let collapse = |names: Vec<String>| -> Vec<String> {
217        let mut m: Vec<String> = names;
218        m.sort();
219        m.dedup();
220        if m.iter().any(|t| t == "number") {
221            m.retain(|t| t != "integer");
222        }
223        m
224    };
225    let dest_family = collapse(dn.clone());
226    let merged = collapse(dn.into_iter().chain(pn).collect());
227    merged != dest_family
228}
229
230/// True when `to` permits null but `from` does not — the column needs its
231/// `NOT NULL` constraint relaxed.
232pub fn adds_null(from: &Value, to: &Value) -> bool {
233    let (_, fnull) = type_set(from);
234    let (_, tnull) = type_set(to);
235    tnull && !fnull
236}
237
238/// Is `s` the exact text of a JSON number — the rendering
239/// [`crate::fidelity`] gives an exact decimal or an integer past `i64`?
240fn is_numeric_text(s: &str) -> bool {
241    let b = s.as_bytes();
242    let mut i = 0;
243    if b.first() == Some(&b'-') {
244        i += 1;
245    }
246    let digits = |i: &mut usize| {
247        let start = *i;
248        while *i < b.len() && b[*i].is_ascii_digit() {
249            *i += 1;
250        }
251        *i > start
252    };
253    if !digits(&mut i) {
254        return false;
255    }
256    if i < b.len() && b[i] == b'.' {
257        i += 1;
258        if !digits(&mut i) {
259            return false;
260        }
261    }
262    if i < b.len() && (b[i] == b'e' || b[i] == b'E') {
263        i += 1;
264        if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
265            i += 1;
266        }
267        if !digits(&mut i) {
268            return false;
269        }
270    }
271    i == b.len()
272}
273
274/// Infer a page's shape for [`diff_schema`] against `destination`.
275///
276/// Like [`crate::schema::infer_schema`], except that a top-level column the
277/// destination stores as `number` whose string values on this page are all
278/// exact numeric text (how [`crate::fidelity`] renders exact decimals and
279/// big integers) is typed `number`: those values land in the column exactly,
280/// so they are not drift. A string that is not numeric text keeps the column
281/// `string`.
282pub fn infer_page_schema(destination: &Value, records: &[Value]) -> Value {
283    let mut inferred = crate::schema::infer_schema(records);
284    let Some(dest_props) = destination.get("properties").and_then(Value::as_object) else {
285        return inferred;
286    };
287    let Some(props) = inferred
288        .get_mut("properties")
289        .and_then(Value::as_object_mut)
290    else {
291        return inferred;
292    };
293    for (name, fragment) in props.iter_mut() {
294        let Some(dest) = dest_props.get(name) else {
295            continue;
296        };
297        let (dn, _) = type_set(dest);
298        let (pn, pnull) = type_set(fragment);
299        if !dn.iter().any(|t| t == "number") || !pn.iter().any(|t| t == "string") {
300            continue;
301        }
302        let all_numeric = records
303            .iter()
304            .filter_map(|r| r.get(name))
305            .all(|v| v.as_str().is_none_or(is_numeric_text));
306        if !all_numeric {
307            continue;
308        }
309        let mut names: Vec<String> = pn
310            .into_iter()
311            .map(|t| if t == "string" { "number".into() } else { t })
312            .collect();
313        if names.iter().any(|t| t == "number") {
314            names.retain(|t| t != "integer");
315        }
316        names.sort();
317        names.dedup();
318        let mut ty: Vec<Value> = names.into_iter().map(Value::String).collect();
319        if pnull {
320            ty.push(Value::String("null".into()));
321        }
322        let ty = if ty.len() == 1 {
323            ty.remove(0)
324        } else {
325            Value::Array(ty)
326        };
327        if let Some(obj) = fragment.as_object_mut() {
328            obj.insert("type".into(), ty);
329        }
330    }
331    inferred
332}
333
334/// Diff a page's inferred shape against the destination schema (top-level columns).
335///
336/// `destination` and `page` are both `infer_schema`-shaped object schemas
337/// (`{"type":"object","properties":{...}}`). `allow_widening` gates whether a
338/// lossless widening lands in `widenings` (true) or `incompatible` (false).
339pub fn diff_schema(destination: &Value, page: &Value, allow_widening: bool) -> SchemaDiff {
340    let empty = serde_json::Map::new();
341    let dest_props = destination
342        .get("properties")
343        .and_then(|p| p.as_object())
344        .unwrap_or(&empty);
345    let page_props = page
346        .get("properties")
347        .and_then(|p| p.as_object())
348        .unwrap_or(&empty);
349
350    let mut diff = SchemaDiff::default();
351
352    for (name, page_ty) in page_props {
353        match dest_props.get(name) {
354            None => diff.additions.push(ColumnChange {
355                name: name.clone(),
356                from: None,
357                to: page_ty.clone(),
358            }),
359            Some(dest_ty) => {
360                if fits(dest_ty, page_ty) {
361                    continue; // page values already acceptable — no drift
362                }
363                let page_ty = &without_also_accepted(dest_ty, page_ty);
364                let change = ColumnChange {
365                    name: name.clone(),
366                    from: Some(dest_ty.clone()),
367                    to: page_ty.clone(),
368                };
369                if allow_widening && evolvable(dest_ty, page_ty) {
370                    diff.widenings.push(change);
371                } else {
372                    diff.incompatible.push(change);
373                }
374            }
375        }
376    }
377
378    // Destination columns absent from the page: drift only if NOT NULL.
379    for (name, dest_ty) in dest_props {
380        if !page_props.contains_key(name) {
381            let (_, nullable) = type_set(dest_ty);
382            if !nullable {
383                diff.droppable_required.push(name.clone());
384            }
385        }
386    }
387    diff.additions.sort_by(|a, b| a.name.cmp(&b.name));
388    diff.widenings.sort_by(|a, b| a.name.cmp(&b.name));
389    diff.incompatible.sort_by(|a, b| a.name.cmp(&b.name));
390    diff.droppable_required.sort();
391    diff
392}
393
394/// What to do when drift is detected.
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
396#[serde(rename_all = "snake_case")]
397pub enum OnDrift {
398    /// Detect + emit a metric and a one-shot log; write the page unchanged.
399    #[default]
400    Warn,
401    /// Apply additive/widening DDL to the destination, then write.
402    Evolve,
403    /// Drop unknown (non-destination) fields from every record; write the rest.
404    Ignore,
405    /// Route the records that exhibit the drift to the DLQ; write the rest.
406    Quarantine,
407    /// Raise `FaucetError::SchemaDrift` and abort.
408    Fail,
409}
410
411/// `evolve`-only: what to do with a narrowing/incompatible change that can't be
412/// auto-applied.
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
414#[serde(rename_all = "snake_case")]
415pub enum OnIncompatible {
416    /// Abort the run (default).
417    #[default]
418    Fail,
419    /// Route the offending records to the DLQ.
420    Quarantine,
421}
422
423fn default_true() -> bool {
424    true
425}
426
427/// User-facing `schema:` config block (pipeline level).
428#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
429#[serde(deny_unknown_fields)]
430pub struct SchemaDriftSpec {
431    /// Policy applied when drift is detected.
432    #[serde(default)]
433    pub on_drift: OnDrift,
434    /// Whether a lossless widening counts as evolvable (vs incompatible).
435    /// Only consulted by `evolve`. Default: true.
436    #[serde(default = "default_true")]
437    pub allow_type_widening: bool,
438    /// `evolve` only: action for an incompatible residue. Default: fail.
439    #[serde(default)]
440    pub on_incompatible: OnIncompatible,
441    /// `evolve` only: whether the **absence** of a destination `NOT NULL`
442    /// column from a page may drop that column's `NOT NULL` constraint.
443    ///
444    /// Default `false`: a column merely omitted from one batch is *not*
445    /// evidence the column is genuinely optional (a transient/partial page
446    /// omits it just as readily as a real schema change), so auto-relaxing
447    /// would silently and irreversibly weaken the destination's integrity
448    /// (issue #194 / F28). When `false`, an omitted required column is left
449    /// untouched — a page that genuinely lacks a required value then fails
450    /// loudly at write time rather than degrading the schema. Set `true` only
451    /// when you deliberately want missing-column omission to relax the
452    /// constraint. Nullability relaxation driven by an *observed* null value
453    /// in a present column (a widening that adds `null`) is unaffected by this
454    /// flag — that is evidence-based, not omission-based.
455    #[serde(default)]
456    pub relax_nullability_on_missing: bool,
457}
458
459/// Compiled, ready-to-run drift policy. Cheap to clone.
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub struct SchemaDriftPolicy {
462    pub on_drift: OnDrift,
463    pub allow_widening: bool,
464    pub on_incompatible: OnIncompatible,
465    /// See [`SchemaDriftSpec::relax_nullability_on_missing`]. Default `false`.
466    pub relax_nullability_on_missing: bool,
467}
468
469impl SchemaDriftPolicy {
470    /// Compile a spec into a runnable policy. Infallible — there is nothing to
471    /// validate that serde hasn't already (the DLQ requirement is enforced by
472    /// the pipeline at run start and by the CLI at config-load).
473    pub fn compile(spec: &SchemaDriftSpec) -> Self {
474        Self {
475            on_drift: spec.on_drift,
476            allow_widening: spec.allow_type_widening,
477            on_incompatible: spec.on_incompatible,
478            relax_nullability_on_missing: spec.relax_nullability_on_missing,
479        }
480    }
481
482    /// `true` when this policy can route records to a DLQ (so one must exist).
483    pub fn requires_dlq(&self) -> bool {
484        self.on_drift == OnDrift::Quarantine
485            || (self.on_drift == OnDrift::Evolve
486                && self.on_incompatible == OnIncompatible::Quarantine)
487    }
488}
489
490/// Backend-neutral base column type inferred from a JSON-Schema fragment.
491/// Each SQL sink maps these to its concrete keyword.
492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493pub enum SqlBaseType {
494    Integer,
495    Double,
496    Boolean,
497    Text,
498    /// Nested object / array → stored as JSON/JSONB/NVARCHAR(MAX) text.
499    Json,
500}
501
502/// Map a top-level JSON-Schema type fragment to a base SQL type, or `None` for
503/// a pure-null fragment (caller falls back to TEXT/NVARCHAR for an added column
504/// whose only observed value was null).
505pub fn json_schema_base_type(fragment: &Value) -> Option<SqlBaseType> {
506    let (names, _nullable) = type_set(fragment);
507    // Prefer the widest informative type among the union.
508    if names.iter().any(|t| t == "object" || t == "array") {
509        return Some(SqlBaseType::Json);
510    }
511    if names.iter().any(|t| t == "string") {
512        return Some(SqlBaseType::Text);
513    }
514    if names.iter().any(|t| t == "number") {
515        return Some(SqlBaseType::Double);
516    }
517    if names.iter().any(|t| t == "integer") {
518        return Some(SqlBaseType::Integer);
519    }
520    if names.iter().any(|t| t == "boolean") {
521        return Some(SqlBaseType::Boolean);
522    }
523    None
524}
525
526#[cfg(test)]
527mod tests {
528    #[test]
529    fn numeric_text_matches_exact_number_renderings_only() {
530        for ok in [
531            "0",
532            "-12",
533            "12.50",
534            "1e10",
535            "-1.5E-3",
536            "123456789012345678901234567890",
537        ] {
538            assert!(super::is_numeric_text(ok), "{ok}");
539        }
540        for bad in [
541            "", "-", "1.", ".5", "1e", "abc", "NaN", "1,5", " 1", "+1", "1e+",
542        ] {
543            assert!(!super::is_numeric_text(bad), "{bad}");
544        }
545    }
546
547    #[test]
548    fn exact_decimal_strings_fit_a_numeric_column() {
549        let dest = json!({"type": "object", "properties": {
550            "amount": {"type": "number"},
551            "n": {"type": ["number", "null"]},
552            "label": {"type": "string"},
553            "count": {"type": "integer"},
554        }});
555        let page = vec![
556            json!({"amount": "12.50", "n": null, "label": "12", "count": "7"}),
557            json!({"amount": 3, "n": "99999999999999999999", "label": "x", "count": 1}),
558        ];
559        let inferred = super::infer_page_schema(&dest, &page);
560        assert_eq!(inferred["properties"]["amount"]["type"], json!("number"));
561        assert_eq!(
562            inferred["properties"]["n"]["type"],
563            json!(["number", "null"])
564        );
565        assert_eq!(inferred["properties"]["label"]["type"], json!("string"));
566        let diff = diff_schema(&dest, &inferred, true);
567        assert!(
568            diff.incompatible
569                .iter()
570                .all(|c| c.name != "amount" && c.name != "n"),
571            "{diff:?}"
572        );
573        // An integer column is not a decimal target: text stays drift.
574        assert!(
575            diff.incompatible.iter().any(|c| c.name == "count"),
576            "{diff:?}"
577        );
578    }
579
580    #[test]
581    fn non_numeric_text_into_a_numeric_column_is_still_drift() {
582        let dest = json!({"type": "object", "properties": {"amount": {"type": "number"}}});
583        let page = vec![json!({"amount": "12.50"}), json!({"amount": "n/a"})];
584        let inferred = super::infer_page_schema(&dest, &page);
585        let diff = diff_schema(&dest, &inferred, true);
586        assert_eq!(diff.incompatible.len(), 1, "{diff:?}");
587        // No destination properties: plain inference.
588        assert_eq!(
589            super::infer_page_schema(&json!({}), &page),
590            crate::schema::infer_schema(&page)
591        );
592    }
593
594    use super::*;
595
596    #[test]
597    fn value_fits_tests_one_value_against_the_destination_column() {
598        let int = json!({"type": "integer"});
599        assert!(value_fits(&int, "n", &json!(7)));
600        assert!(!value_fits(&int, "n", &json!(1.5)));
601        assert!(!value_fits(&int, "n", &json!("x")));
602        assert!(
603            !value_fits(&int, "n", &Value::Null),
604            "a NOT NULL column refuses null"
605        );
606        let nullable = json!({"type": ["number", "null"]});
607        assert!(value_fits(&nullable, "n", &json!(1.5)));
608        assert!(value_fits(&nullable, "n", &json!(7)));
609        assert!(value_fits(&nullable, "n", &Value::Null));
610        // Exact numeric text lands in a number column (fidelity rendering).
611        assert!(value_fits(&nullable, "n", &json!("12.50")));
612    }
613    use serde_json::json;
614
615    fn schema(props: Value) -> Value {
616        json!({ "type": "object", "properties": props })
617    }
618
619    #[test]
620    fn also_accepts_lets_extra_types_fit_without_widening() {
621        let dest = schema(json!({
622            "flag": {"type": ["integer", "null"], DRIFT_ALSO_ACCEPTS: ["boolean"]},
623            "doc": {"type": ["string", "null"], DRIFT_ALSO_ACCEPTS: ["object", "array"]},
624        }));
625        let page = schema(json!({
626            "flag": {"type": "boolean"},
627            "doc": {"type": ["object", "array"]},
628        }));
629        assert!(diff_schema(&dest, &page, true).is_empty());
630        let page = schema(json!({"flag": {"type": "number"}}));
631        let d = diff_schema(&dest, &page, true);
632        assert_eq!(d.widenings.len(), 1);
633        let page = schema(json!({"flag": {"type": ["boolean", "number"]}}));
634        let d = diff_schema(&dest, &page, true);
635        assert_eq!(d.widenings.len(), 1, "{d:?}");
636        assert_eq!(d.widenings[0].to, json!({"type": "number"}));
637        assert!(base_widened(
638            d.widenings[0].from.as_ref().unwrap(),
639            &d.widenings[0].to
640        ));
641        let strict = schema(json!({
642            "flag": {"type": "integer", DRIFT_ALSO_ACCEPTS: ["boolean"]},
643        }));
644        let page = schema(json!({"flag": {"type": ["boolean", "null"]}}));
645        let d = diff_schema(&strict, &page, true);
646        assert_eq!(d.widenings[0].to, json!({"type": "null"}));
647        assert!(adds_null(
648            d.widenings[0].from.as_ref().unwrap(),
649            &d.widenings[0].to
650        ));
651        let plain = schema(json!({"flag": {"type": ["integer", "null"]}}));
652        let page = schema(json!({"flag": {"type": "boolean"}}));
653        assert_eq!(diff_schema(&plain, &page, true).incompatible.len(), 1);
654    }
655
656    #[test]
657    fn sql_type_mapping() {
658        use super::SqlBaseType::*;
659        assert_eq!(
660            json_schema_base_type(&json!({"type":"integer"})),
661            Some(Integer)
662        );
663        assert_eq!(
664            json_schema_base_type(&json!({"type":"number"})),
665            Some(Double)
666        );
667        assert_eq!(
668            json_schema_base_type(&json!({"type":"boolean"})),
669            Some(Boolean)
670        );
671        assert_eq!(json_schema_base_type(&json!({"type":"string"})), Some(Text));
672        assert_eq!(
673            json_schema_base_type(&json!({"type":["string","null"]})),
674            Some(Text)
675        );
676        assert_eq!(json_schema_base_type(&json!({"type":"object"})), Some(Json));
677        assert_eq!(json_schema_base_type(&json!({"type":"array"})), Some(Json));
678        assert_eq!(json_schema_base_type(&json!({"type":"null"})), None);
679    }
680
681    #[test]
682    fn no_drift_when_shapes_match() {
683        let dest = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
684        let page = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
685        let d = diff_schema(&dest, &page, true);
686        assert!(d.is_empty(), "got {d:?}");
687    }
688
689    #[test]
690    fn detects_addition() {
691        let dest = schema(json!({ "id": {"type": "integer"} }));
692        let page = schema(json!({ "id": {"type": "integer"}, "email": {"type": "string"} }));
693        let d = diff_schema(&dest, &page, true);
694        assert_eq!(d.additions.len(), 1);
695        assert_eq!(d.additions[0].name, "email");
696        assert!(d.additions[0].from.is_none());
697        assert_eq!(d.additions[0].to, json!({"type": "string"}));
698        assert!(d.widenings.is_empty() && d.incompatible.is_empty());
699    }
700
701    #[test]
702    fn integer_to_number_is_widening_when_allowed() {
703        let dest = schema(json!({ "score": {"type": "integer"} }));
704        let page = schema(json!({ "score": {"type": "number"} }));
705        let d = diff_schema(&dest, &page, true);
706        assert_eq!(d.widenings.len(), 1, "got {d:?}");
707        assert_eq!(d.widenings[0].name, "score");
708        assert!(d.incompatible.is_empty());
709    }
710
711    #[test]
712    fn integer_to_number_is_incompatible_when_widening_disallowed() {
713        let dest = schema(json!({ "score": {"type": "integer"} }));
714        let page = schema(json!({ "score": {"type": "number"} }));
715        let d = diff_schema(&dest, &page, false);
716        assert_eq!(d.incompatible.len(), 1, "got {d:?}");
717        assert!(d.widenings.is_empty());
718    }
719
720    #[test]
721    fn gaining_nullability_is_widening() {
722        let dest = schema(json!({ "name": {"type": "string"} }));
723        let page = schema(json!({ "name": {"type": ["string", "null"]} }));
724        let d = diff_schema(&dest, &page, true);
725        assert_eq!(d.widenings.len(), 1, "got {d:?}");
726    }
727
728    #[test]
729    fn string_to_integer_is_incompatible() {
730        let dest = schema(json!({ "id": {"type": "string"} }));
731        let page = schema(json!({ "id": {"type": "integer"} }));
732        let d = diff_schema(&dest, &page, true);
733        assert_eq!(d.incompatible.len(), 1, "got {d:?}");
734        assert!(d.widenings.is_empty());
735    }
736
737    #[test]
738    fn required_destination_column_absent_from_page_is_droppable_required() {
739        // Destination has a non-nullable `created_at` the page never provides.
740        let dest = schema(json!({
741            "id": {"type": "integer"},
742            "created_at": {"type": "string"}
743        }));
744        let page = schema(json!({ "id": {"type": "integer"} }));
745        let d = diff_schema(&dest, &page, true);
746        assert_eq!(
747            d.droppable_required,
748            vec!["created_at".to_string()],
749            "got {d:?}"
750        );
751    }
752
753    #[test]
754    fn nullable_destination_column_absent_from_page_is_not_drift() {
755        // A column the destination already allows to be null is fine to omit.
756        let dest = schema(json!({
757            "id": {"type": "integer"},
758            "note": {"type": ["string", "null"]}
759        }));
760        let page = schema(json!({ "id": {"type": "integer"} }));
761        let d = diff_schema(&dest, &page, true);
762        assert!(d.is_empty(), "got {d:?}");
763    }
764
765    #[test]
766    fn nested_object_treated_as_single_column() {
767        // A change *inside* a nested object is invisible — top-level only.
768        let dest =
769            schema(json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}}} }));
770        let page = schema(
771            json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "string"}}} }),
772        );
773        let d = diff_schema(&dest, &page, true);
774        assert!(
775            d.is_empty(),
776            "nested changes must not surface as drift; got {d:?}"
777        );
778    }
779
780    /// Which bucket a single-column diff landed in.
781    #[derive(Debug, PartialEq)]
782    enum Bucket {
783        None,
784        Widening,
785        Incompatible,
786    }
787
788    /// Diff a single column `col` (D vs P) and report which bucket it landed in.
789    fn classify_one(dest_ty: Value, page_ty: Value, allow_widening: bool) -> Bucket {
790        let dest = schema(json!({ "col": dest_ty }));
791        let page = schema(json!({ "col": page_ty }));
792        let d = diff_schema(&dest, &page, allow_widening);
793        assert!(d.additions.is_empty(), "unexpected addition: {d:?}");
794        assert!(
795            d.droppable_required.is_empty(),
796            "unexpected droppable: {d:?}"
797        );
798        match (d.widenings.len(), d.incompatible.len()) {
799            (0, 0) => Bucket::None,
800            (1, 0) => Bucket::Widening,
801            (0, 1) => Bucket::Incompatible,
802            _ => panic!("ambiguous classification: {d:?}"),
803        }
804    }
805
806    #[test]
807    fn truth_table_allow_widening() {
808        use Bucket::*;
809        // (dest, page, expected bucket) — allow_widening = true.
810        let cases: &[(Value, Value, Bucket)] = &[
811            (json!({"type": "integer"}), json!({"type": "integer"}), None),
812            (json!({"type": "string"}), json!({"type": "string"}), None),
813            (
814                json!({"type": ["string", "null"]}),
815                json!({"type": ["string", "null"]}),
816                None,
817            ),
818            // Regression guard: non-null page fits a nullable dest.
819            (
820                json!({"type": ["string", "null"]}),
821                json!({"type": "string"}),
822                None,
823            ),
824            // Regression guard: integer fits a number column.
825            (json!({"type": "number"}), json!({"type": "integer"}), None),
826            // integer → number widening.
827            (
828                json!({"type": "integer"}),
829                json!({"type": "number"}),
830                Widening,
831            ),
832            // string → nullable string (relax null).
833            (
834                json!({"type": "string"}),
835                json!({"type": ["string", "null"]}),
836                Widening,
837            ),
838            // integer → nullable number (int→number + null relax).
839            (
840                json!({"type": "integer"}),
841                json!({"type": ["number", "null"]}),
842                Widening,
843            ),
844            // nullable integer dest, number page → int→number, dest already nullable.
845            (
846                json!({"type": ["integer", "null"]}),
847                json!({"type": "number"}),
848                Widening,
849            ),
850            // Genuine incompatibilities.
851            (
852                json!({"type": "string"}),
853                json!({"type": "integer"}),
854                Incompatible,
855            ),
856            (
857                json!({"type": "integer"}),
858                json!({"type": "string"}),
859                Incompatible,
860            ),
861            (
862                json!({"type": "boolean"}),
863                json!({"type": "number"}),
864                Incompatible,
865            ),
866        ];
867        for (dest, page, want) in cases {
868            let got = classify_one(dest.clone(), page.clone(), true);
869            assert_eq!(
870                &got, want,
871                "allow_widening=true: D={dest} P={page} expected {want:?} got {got:?}"
872            );
873        }
874    }
875
876    #[test]
877    fn truth_table_widening_disallowed() {
878        use Bucket::*;
879        // (dest, page, expected bucket) — allow_widening = false.
880        let cases: &[(Value, Value, Bucket)] = &[
881            // int→number is no longer a widening; with widening off it is incompatible.
882            (
883                json!({"type": "integer"}),
884                json!({"type": "number"}),
885                Incompatible,
886            ),
887            // `fits` still applies regardless of allow_widening.
888            (
889                json!({"type": ["string", "null"]}),
890                json!({"type": "string"}),
891                None,
892            ),
893            // null relaxation is a widening, not a fit → incompatible when disallowed.
894            (
895                json!({"type": "string"}),
896                json!({"type": ["string", "null"]}),
897                Incompatible,
898            ),
899        ];
900        for (dest, page, want) in cases {
901            let got = classify_one(dest.clone(), page.clone(), false);
902            assert_eq!(
903                &got, want,
904                "allow_widening=false: D={dest} P={page} expected {want:?} got {got:?}"
905            );
906        }
907    }
908
909    #[test]
910    fn base_widened_detects_base_type_change() {
911        // integer → number is a base-type widening (needs ALTER TYPE).
912        assert!(base_widened(
913            &json!({"type": "integer"}),
914            &json!({"type": "number"})
915        ));
916        // nullability-only relaxation is NOT a base-type widening.
917        assert!(!base_widened(
918            &json!({"type": "string"}),
919            &json!({"type": ["string", "null"]})
920        ));
921        // identical base type, no change.
922        assert!(!base_widened(
923            &json!({"type": "integer"}),
924            &json!({"type": "integer"})
925        ));
926        // integer dest, nullable number page → base family changed.
927        assert!(base_widened(
928            &json!({"type": "integer"}),
929            &json!({"type": ["number", "null"]})
930        ));
931        // number dest, integer page → integer collapses into number, no change.
932        assert!(!base_widened(
933            &json!({"type": "number"}),
934            &json!({"type": "integer"})
935        ));
936    }
937
938    #[test]
939    fn adds_null_detects_nullability_relaxation() {
940        assert!(adds_null(
941            &json!({"type": "string"}),
942            &json!({"type": ["string", "null"]})
943        ));
944        // already nullable destination → not adding null.
945        assert!(!adds_null(
946            &json!({"type": ["string", "null"]}),
947            &json!({"type": "string"})
948        ));
949        assert!(!adds_null(
950            &json!({"type": "string"}),
951            &json!({"type": "string"})
952        ));
953        // page nullable, dest not → adds null even with a base change.
954        assert!(adds_null(
955            &json!({"type": "integer"}),
956            &json!({"type": ["number", "null"]})
957        ));
958    }
959
960    #[test]
961    fn spec_defaults() {
962        let spec: SchemaDriftSpec = serde_json::from_str("{}").unwrap();
963        assert_eq!(spec.on_drift, OnDrift::Warn);
964        assert!(spec.allow_type_widening);
965        assert_eq!(spec.on_incompatible, OnIncompatible::Fail);
966    }
967
968    #[test]
969    fn on_drift_serializes_snake_case() {
970        assert_eq!(
971            serde_json::to_string(&OnDrift::Evolve).unwrap(),
972            "\"evolve\""
973        );
974        assert_eq!(
975            serde_json::to_string(&OnDrift::Quarantine).unwrap(),
976            "\"quarantine\""
977        );
978    }
979
980    #[test]
981    fn policy_compile_carries_flags() {
982        let spec: SchemaDriftSpec =
983            serde_json::from_str(r#"{"on_drift":"evolve","allow_type_widening":false}"#).unwrap();
984        let policy = SchemaDriftPolicy::compile(&spec);
985        assert_eq!(policy.on_drift, OnDrift::Evolve);
986        assert!(!policy.allow_widening);
987        assert_eq!(policy.on_incompatible, OnIncompatible::Fail);
988    }
989
990    #[test]
991    fn policy_requires_dlq_only_for_quarantine_paths() {
992        let q: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"quarantine"}"#).unwrap();
993        assert!(SchemaDriftPolicy::compile(&q).requires_dlq());
994        let evo_q: SchemaDriftSpec =
995            serde_json::from_str(r#"{"on_drift":"evolve","on_incompatible":"quarantine"}"#)
996                .unwrap();
997        assert!(SchemaDriftPolicy::compile(&evo_q).requires_dlq());
998        let warn: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"warn"}"#).unwrap();
999        assert!(!SchemaDriftPolicy::compile(&warn).requires_dlq());
1000    }
1001}