Skip to main content

rivet/preflight/
type_report.rs

1//! `rivet check --type-report` — tabular and JSON output.
2//!
3//! Roadmap §9 ("Type Fidelity Report") and §16 ("BigQuery Compatibility Layer").
4//! Renders a `Vec<TypeMapping>` plus any `PolicyViolation`s as either a
5//! fixed-width terminal table or newline-delimited JSON.
6
7use serde::Serialize;
8
9use crate::config::{Config, ExportConfig, FormatType, SourceType};
10use crate::error::Result;
11use crate::source;
12use crate::types::{
13    ColumnOverrides, TypeFidelity,
14    policy::{PolicyAction, PolicyViolation, TypePolicy},
15    target::{ExportTarget, TargetInput, TargetStatus},
16};
17
18/// One row in the type report (and the JSON output — roadmap §9).
19#[derive(Serialize)]
20pub struct TypeReportRow {
21    pub column: String,
22    pub source_type: String,
23    pub rivet_type: String,
24    pub arrow_type: String,
25    pub fidelity: TypeFidelity,
26    #[serde(skip_serializing_if = "Vec::is_empty")]
27    pub warnings: Vec<String>,
28    /// Present when `--target` is set.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub target_type: Option<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub target_status: Option<TargetStatus>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub target_note: Option<String>,
35    /// Type a generic Parquet reader infers without a declared schema, surfaced
36    /// only when it diverges from `target_type` (e.g. BigQuery autoloads JSON
37    /// as BYTES). Present when `--target` is set and autoload ≠ native.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub autoload_type: Option<String>,
40    /// Materialization / load-schema hint (L5) to recover the native type.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub cast_sql: Option<String>,
43}
44
45/// One export's type-report data.
46#[derive(Serialize)]
47pub struct ExportTypeReport {
48    pub export: String,
49    pub columns: Vec<TypeReportRow>,
50    pub violations: Vec<PolicyViolation>,
51    /// True when any column failed target-compatibility.
52    #[serde(skip_serializing_if = "std::ops::Not::not")]
53    pub target_failures: bool,
54    /// Target-native recovery SQL (ADR-0014 L5): a post-load transform that
55    /// recovers types bare autoload degrades (BigQuery JSON/UUID/DATETIME).
56    /// `None` for targets that autoload faithfully (DuckDB) or when no target
57    /// is set.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub recovery_sql: Option<String>,
60}
61
62impl ExportTypeReport {
63    pub fn has_fatal(&self) -> bool {
64        self.violations.iter().any(|v| v.fatal)
65    }
66
67    pub fn has_target_fail(&self) -> bool {
68        self.target_failures
69    }
70}
71
72/// Collect type mappings for one export from a live connection.
73pub fn collect_report(
74    config: &Config,
75    export: &ExportConfig,
76    column_overrides: &ColumnOverrides,
77    policy: &TypePolicy,
78    target: Option<ExportTarget>,
79    config_dir: &std::path::Path,
80    params: Option<&std::collections::HashMap<String, String>>,
81) -> Result<ExportTypeReport> {
82    let url = config.source.resolve_url()?;
83    let tls = config.source.tls.as_ref();
84    // Resolve the effective query the same way the export pipeline does, so the
85    // `table:` shortcut (and `query_file:` / `${var}` params) produce a real
86    // query instead of an empty string.
87    let query = export.resolve_query(config_dir, params)?;
88
89    let mut src: Box<dyn source::Source> = match config.source.source_type {
90        SourceType::Postgres => Box::new(source::postgres::PostgresSource::connect_with_tls(
91            &url, tls,
92        )?),
93        SourceType::Mysql => Box::new(source::mysql::MysqlSource::connect_with_tls(&url, tls)?),
94        SourceType::Mssql => Box::new(source::mssql::MssqlSource::connect_with_tls(&url, tls)?),
95        SourceType::Mongo => Box::new(source::mongo::MongoSource::connect(&url, tls, None)?),
96    };
97
98    let mut mappings = src.type_mappings(&query, column_overrides)?;
99
100    // #32 (column-applicability): a `columns:` override that *narrows* the
101    // source type — e.g. `price numeric(10,2)` → `decimal(20,0)` — drops the
102    // two fractional digits at `run`, but `derive_fidelity` only sees the
103    // resolved (overridden) `RivetType` and labels it `exact`. That makes
104    // `check --type-report` disagree with what `run` actually does (a
105    // check↔run gap). Re-probe the *autodetected* source types (no overrides)
106    // and downgrade any overridden column whose override narrows scale or
107    // integer-digit capacity to `Lossy`, with a warning that says why. We only
108    // downgrade when the source type is confidently known (autodetect resolved
109    // it) — never on a guess, so we don't fabricate a loss we can't prove.
110    if !column_overrides.is_empty() {
111        let source_mappings = src.type_mappings(&query, &ColumnOverrides::new())?;
112        let source_by_name: std::collections::HashMap<&str, &crate::types::RivetType> =
113            source_mappings
114                .iter()
115                .map(|m| (m.column_name.as_str(), &m.rivet_type))
116                .collect();
117        for m in &mut mappings {
118            if !column_overrides.contains_key(&m.column_name) {
119                continue;
120            }
121            if let Some(&src_type) = source_by_name.get(m.column_name.as_str())
122                && let Some(reason) = override_narrows(src_type, &m.rivet_type)
123            {
124                m.fidelity = TypeFidelity::Lossy;
125                m.warnings.push(reason);
126            }
127        }
128    }
129
130    let mut violations = policy.validate(&mappings);
131
132    // Format-awareness: type resolution above is for the Parquet representation,
133    // but a CSV export rejects columns CSV can't serialize (lists, etc.) up front
134    // at writer creation. Surface those here so `check`/`--strict` agree with the
135    // run — otherwise a list column reports "safe" only for the CSV run to fail
136    // loud ("CSV cannot serialize column …"). Fatality follows the unsupported
137    // policy action (Fail under `--strict`, Warn otherwise).
138    if export.format == FormatType::Csv {
139        let fatal = policy.on_unsupported_type == PolicyAction::Fail;
140        for m in &mappings {
141            if let Some(dt) = m.arrow_type.as_ref()
142                && !crate::format::csv::csv_serializable(dt)
143            {
144                violations.push(PolicyViolation {
145                    column_name: m.column_name.clone(),
146                    fidelity: TypeFidelity::Unsupported,
147                    message: format!(
148                        "column '{}' (Arrow {dt:?}) cannot be serialized to CSV — \
149                         use `format: parquet` or drop it from the query",
150                        m.column_name
151                    ),
152                    fatal,
153                });
154            }
155        }
156    }
157
158    let mut target_failures = false;
159    let rows = mappings
160        .iter()
161        .map(|m| {
162            let (target_type, target_status, target_note, autoload_type, cast_sql) =
163                if let Some(tgt) = target {
164                    let spec = tgt.resolve_column(TargetInput::from(m));
165                    if spec.status == TargetStatus::Fail {
166                        target_failures = true;
167                    }
168                    // Surface the autoloaded type only when it diverges from the
169                    // native type — that divergence is the operator-facing point.
170                    let autoload =
171                        (spec.autoload_type != spec.target_type).then_some(spec.autoload_type);
172                    (
173                        Some(spec.target_type),
174                        Some(spec.status),
175                        spec.note,
176                        autoload,
177                        spec.cast_sql,
178                    )
179                } else {
180                    (None, None, None, None, None)
181                };
182            TypeReportRow {
183                column: m.column_name.clone(),
184                source_type: m.source_native_type.clone(),
185                rivet_type: rivet_type_label(&m.rivet_type),
186                arrow_type: m
187                    .arrow_type
188                    .as_ref()
189                    .map(|t| format!("{t:?}"))
190                    .unwrap_or_else(|| "-".into()),
191                fidelity: m.fidelity,
192                warnings: m.warnings.clone(),
193                target_type,
194                target_status,
195                target_note,
196                autoload_type,
197                cast_sql,
198            }
199        })
200        .collect();
201
202    // L5 recovery SQL (ADR-0014): a post-load transform for operators whose
203    // bare autoload would degrade types. `None` for DuckDB (faithful autoload)
204    // or when no target is set.
205    let recovery_sql =
206        target.and_then(|t| t.recovery_sql(&t.resolve_table(&mappings), &export.name));
207
208    Ok(ExportTypeReport {
209        export: export.name.clone(),
210        columns: rows,
211        violations,
212        target_failures,
213        recovery_sql,
214    })
215}
216
217/// Print the report as a human-readable table to stdout.
218pub fn print_table(report: &ExportTypeReport, target: Option<ExportTarget>) {
219    let col_w = col_width(&report.columns, |r| r.column.len());
220    let src_w = col_width(&report.columns, |r| r.source_type.len()).max("Source type".len());
221    let rv_w = col_width(&report.columns, |r| r.rivet_type.len()).max("Rivet type".len());
222    let arr_w = col_width(&report.columns, |r| r.arrow_type.len()).max("Arrow type".len());
223    let fid_w = "logical_string".len();
224
225    println!();
226    if let Some(tgt) = target {
227        println!("Export: {}  [target: {}]", report.export, tgt.label());
228    } else {
229        println!("Export: {}", report.export);
230    }
231
232    if target.is_some() {
233        let tgt_w = col_width(&report.columns, |r| {
234            r.target_type.as_deref().unwrap_or("-").len()
235        })
236        .max("Target type".len());
237        let sta_w = "Status".len();
238
239        println!(
240            "  {:<col_w$}  {:<src_w$}  {:<rv_w$}  {:<arr_w$}  {:<fid_w$}  {:<tgt_w$}  {:<sta_w$}",
241            "Column",
242            "Source type",
243            "Rivet type",
244            "Arrow type",
245            "Fidelity",
246            "Target type",
247            "Status"
248        );
249        println!(
250            "  {:-<col_w$}  {:-<src_w$}  {:-<rv_w$}  {:-<arr_w$}  {:-<fid_w$}  {:-<tgt_w$}  {:-<sta_w$}",
251            "", "", "", "", "", "", ""
252        );
253        for row in &report.columns {
254            let status_label = row.target_status.as_ref().map(|s| s.label()).unwrap_or("-");
255            let tgt_type = row.target_type.as_deref().unwrap_or("-");
256            let status_marker = match &row.target_status {
257                Some(TargetStatus::Fail) => " ✗",
258                Some(TargetStatus::Warn) => " ~",
259                _ => "",
260            };
261            println!(
262                "  {:<col_w$}  {:<src_w$}  {:<rv_w$}  {:<arr_w$}  {}{:<rest$}  {:<tgt_w$}  {}{}",
263                row.column,
264                row.source_type,
265                row.rivet_type,
266                row.arrow_type,
267                row.fidelity.label(),
268                "",
269                tgt_type,
270                status_label,
271                status_marker,
272                rest = fid_w - row.fidelity.label().len(),
273            );
274            if let Some(autoload) = &row.autoload_type {
275                println!("  {:<col_w$}    autoload: {}", "", autoload);
276            }
277            if let Some(note) = &row.target_note {
278                println!("  {:<col_w$}    note: {}", "", note);
279            }
280            if let Some(cast) = &row.cast_sql {
281                println!("  {:<col_w$}    recover: {}", "", cast);
282            }
283            for w in &row.warnings {
284                println!("  {:<col_w$}    warning: {}", "", w);
285            }
286        }
287    } else {
288        println!(
289            "  {:<col_w$}  {:<src_w$}  {:<rv_w$}  {:<arr_w$}  {:<fid_w$}",
290            "Column", "Source type", "Rivet type", "Arrow type", "Fidelity"
291        );
292        println!(
293            "  {:-<col_w$}  {:-<src_w$}  {:-<rv_w$}  {:-<arr_w$}  {:-<fid_w$}",
294            "", "", "", "", ""
295        );
296        for row in &report.columns {
297            println!(
298                "  {:<col_w$}  {:<src_w$}  {:<rv_w$}  {:<arr_w$}  {}{}",
299                row.column,
300                row.source_type,
301                row.rivet_type,
302                row.arrow_type,
303                row.fidelity.label(),
304                fidelity_marker(row.fidelity),
305            );
306            for w in &row.warnings {
307                println!("  {:<col_w$}    warning: {}", "", w);
308            }
309        }
310    }
311
312    if !report.violations.is_empty() {
313        println!();
314        for v in &report.violations {
315            let prefix = if v.fatal { "  FAIL" } else { "  WARN" };
316            println!("{}: {}", prefix, v.message);
317        }
318    }
319
320    if let Some(sql) = &report.recovery_sql {
321        println!();
322        println!(
323            "  {} type recovery — bare autoload degrades JSON/UUID→BYTES, naive",
324            target.map(|t| t.label()).unwrap_or("target")
325        );
326        println!("  timestamp→TIMESTAMP, array→RECORD; load with --autodetect then run:");
327        for line in sql.lines() {
328            println!("    {line}");
329        }
330    }
331}
332
333fn col_width(rows: &[TypeReportRow], f: impl Fn(&TypeReportRow) -> usize) -> usize {
334    rows.iter().map(f).max().unwrap_or(8).max(8)
335}
336
337fn fidelity_marker(f: TypeFidelity) -> &'static str {
338    match f {
339        TypeFidelity::Lossy | TypeFidelity::Unsupported => " ✗",
340        TypeFidelity::LogicalString => " ~",
341        _ => "",
342    }
343}
344
345/// Detect whether a `columns:` override *narrows* the autodetected source type
346/// in a value-losing way. Returns `Some(reason)` to flag the column `Lossy`,
347/// `None` when the override preserves or widens the type (or when the comparison
348/// is not applicable).
349///
350/// Today this covers the decimal case the audit exercises (#32): a scale or
351/// integer-digit reduction on `numeric`/`decimal`. A scale reduction
352/// (`numeric(10,2)` → `decimal(20,0)`) silently truncates the fractional digits
353/// at `run`; an integer-digit reduction (`(20,0)` → `(10,0)`) overflows the
354/// declared precision. Either is genuinely lossy and must not be reported
355/// `exact`. Widening or an equal scale/precision is fine. Non-decimal overrides
356/// are left to `derive_fidelity` (not narrowing-classified here).
357fn override_narrows(
358    source: &crate::types::RivetType,
359    overridden: &crate::types::RivetType,
360) -> Option<String> {
361    use crate::types::RivetType::Decimal;
362    if let (
363        Decimal {
364            precision: sp,
365            scale: ss,
366        },
367        Decimal {
368            precision: op,
369            scale: os,
370        },
371    ) = (source, overridden)
372    {
373        // Fractional-digit loss: the override keeps fewer digits to the right
374        // of the point than the source declared.
375        if os < ss {
376            return Some(format!(
377                "override decimal({op},{os}) reduces scale from source numeric({sp},{ss}) — \
378                 {} fractional digit(s) are truncated at run; this is lossy, not exact",
379                (*ss as i16) - (*os as i16)
380            ));
381        }
382        // Integer-digit loss: the override leaves fewer digits to the left of
383        // the point than the source could hold, so large values overflow.
384        let src_int_digits = *sp as i16 - *ss as i16;
385        let ov_int_digits = *op as i16 - *os as i16;
386        if ov_int_digits < src_int_digits {
387            return Some(format!(
388                "override decimal({op},{os}) reduces integer-digit capacity from source \
389                 numeric({sp},{ss}) — large values overflow at run; this is lossy, not exact"
390            ));
391        }
392    }
393    None
394}
395
396fn rivet_type_label(t: &crate::types::RivetType) -> String {
397    use crate::types::RivetType::*;
398    match t {
399        Bool => "bool".into(),
400        Int16 => "int2".into(),
401        Int32 => "int4".into(),
402        Int64 => "int8".into(),
403        UInt64 => "uint8".into(),
404        Float32 => "float4".into(),
405        Float64 => "float8".into(),
406        Decimal { precision, scale } => format!("decimal({precision},{scale})"),
407        Date => "date".into(),
408        Time { .. } => "time".into(),
409        Timestamp {
410            timezone: Some(_), ..
411        } => "timestamp_tz".into(),
412        Timestamp { timezone: None, .. } => "timestamp".into(),
413        String => "text".into(),
414        Text => "text".into(),
415        Binary => "binary".into(),
416        Json => "json".into(),
417        Uuid => "uuid".into(),
418        Enum => "enum".into(),
419        Interval => "interval".into(),
420        List { inner } => format!("list<{}>", rivet_type_label(inner)),
421        Unsupported { native_type, .. } => format!("unsupported({native_type})"),
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::types::{RivetType, TypeFidelity};
429
430    // ── override_narrows (#32: lossy scale/precision narrowing) ──────────────
431
432    fn dec(precision: u8, scale: i8) -> RivetType {
433        RivetType::Decimal { precision, scale }
434    }
435
436    #[test]
437    fn narrows_flags_scale_reduction_as_lossy() {
438        // The audit case: numeric(10,2) overridden to decimal(20,0) drops the
439        // two fractional digits — must be flagged, never 'exact'.
440        let reason = override_narrows(&dec(10, 2), &dec(20, 0)).expect("scale drop is lossy");
441        assert!(
442            reason.contains("scale"),
443            "reason should name scale: {reason}"
444        );
445        assert!(
446            reason.contains("lossy"),
447            "reason should say lossy: {reason}"
448        );
449    }
450
451    #[test]
452    fn narrows_none_when_scale_preserved() {
453        // Same scale, wider precision: no fractional loss → not narrowing.
454        assert!(override_narrows(&dec(10, 2), &dec(20, 2)).is_none());
455        // Identical type: not narrowing.
456        assert!(override_narrows(&dec(10, 2), &dec(10, 2)).is_none());
457    }
458
459    #[test]
460    fn narrows_none_when_scale_widened() {
461        // More fractional digits than the source declared preserves every value.
462        assert!(override_narrows(&dec(10, 2), &dec(12, 4)).is_none());
463    }
464
465    #[test]
466    fn narrows_flags_integer_digit_reduction_as_lossy() {
467        // Same scale but fewer integer digits: (20,0) → (10,0) overflows large
468        // values, so it is lossy even though the scale is unchanged.
469        let reason =
470            override_narrows(&dec(20, 0), &dec(10, 0)).expect("integer-digit drop is lossy");
471        assert!(
472            reason.contains("integer-digit") && reason.contains("lossy"),
473            "reason: {reason}"
474        );
475    }
476
477    #[test]
478    fn narrows_none_for_non_decimal_overrides() {
479        // Non-decimal overrides are classified by derive_fidelity, not here.
480        assert!(override_narrows(&RivetType::Int32, &RivetType::Int64).is_none());
481        assert!(override_narrows(&RivetType::Int64, &RivetType::String).is_none());
482    }
483
484    // ── fidelity_marker ──────────────────────────────────────────────────────
485
486    #[test]
487    fn fidelity_marker_lossy_is_cross() {
488        assert_eq!(fidelity_marker(TypeFidelity::Lossy), " ✗");
489    }
490
491    #[test]
492    fn fidelity_marker_unsupported_is_cross() {
493        assert_eq!(fidelity_marker(TypeFidelity::Unsupported), " ✗");
494    }
495
496    #[test]
497    fn fidelity_marker_logical_string_is_tilde() {
498        assert_eq!(fidelity_marker(TypeFidelity::LogicalString), " ~");
499    }
500
501    #[test]
502    fn fidelity_marker_exact_is_empty() {
503        assert_eq!(fidelity_marker(TypeFidelity::Exact), "");
504    }
505
506    #[test]
507    fn fidelity_marker_compatible_is_empty() {
508        assert_eq!(fidelity_marker(TypeFidelity::Compatible), "");
509    }
510
511    // ── rivet_type_label ─────────────────────────────────────────────────────
512
513    #[test]
514    fn label_bool() {
515        assert_eq!(rivet_type_label(&RivetType::Bool), "bool");
516    }
517
518    #[test]
519    fn label_int64() {
520        assert_eq!(rivet_type_label(&RivetType::Int64), "int8");
521    }
522
523    #[test]
524    fn label_float64() {
525        assert_eq!(rivet_type_label(&RivetType::Float64), "float8");
526    }
527
528    #[test]
529    fn label_decimal_with_precision_and_scale() {
530        assert_eq!(
531            rivet_type_label(&RivetType::Decimal {
532                precision: 18,
533                scale: 2
534            }),
535            "decimal(18,2)"
536        );
537    }
538
539    #[test]
540    fn label_text() {
541        assert_eq!(rivet_type_label(&RivetType::Text), "text");
542    }
543
544    #[test]
545    fn label_uuid() {
546        assert_eq!(rivet_type_label(&RivetType::Uuid), "uuid");
547    }
548
549    #[test]
550    fn label_list_of_int64() {
551        let t = RivetType::List {
552            inner: Box::new(RivetType::Int64),
553        };
554        assert_eq!(rivet_type_label(&t), "list<int8>");
555    }
556
557    #[test]
558    fn label_unsupported_native_type() {
559        let t = RivetType::Unsupported {
560            native_type: "tsvector".into(),
561            reason: "not supported".into(),
562        };
563        assert_eq!(rivet_type_label(&t), "unsupported(tsvector)");
564    }
565
566    // ── col_width ────────────────────────────────────────────────────────────
567
568    #[test]
569    fn col_width_empty_returns_minimum_8() {
570        let rows: Vec<TypeReportRow> = vec![];
571        assert_eq!(col_width(&rows, |_r| 0), 8);
572    }
573
574    #[test]
575    fn col_width_short_values_returns_minimum_8() {
576        let row = TypeReportRow {
577            column: "a".into(),
578            source_type: "b".into(),
579            rivet_type: "c".into(),
580            arrow_type: "d".into(),
581            fidelity: TypeFidelity::Exact,
582            warnings: vec![],
583            target_type: None,
584            target_status: None,
585            target_note: None,
586            autoload_type: None,
587            cast_sql: None,
588        };
589        assert_eq!(col_width(&[row], |r| r.column.len()), 8);
590    }
591
592    #[test]
593    fn col_width_long_value_returns_that_length() {
594        let row = TypeReportRow {
595            column: "a_very_long_column_name".into(),
596            source_type: "int8".into(),
597            rivet_type: "int8".into(),
598            arrow_type: "Int64".into(),
599            fidelity: TypeFidelity::Exact,
600            warnings: vec![],
601            target_type: None,
602            target_status: None,
603            target_note: None,
604            autoload_type: None,
605            cast_sql: None,
606        };
607        let w = col_width(&[row], |r| r.column.len());
608        assert_eq!(w, "a_very_long_column_name".len());
609    }
610}