Skip to main content

rich_data/
quality.rs

1//! Data quality results (#269): a check-result model and a report that
2//! renders it the way `rich_ext`'s test report renders test runs.
3//!
4//! This is not a check engine. A [`CheckResult`] is whatever a caller's
5//! checks found: the check's name, the column it looked at, a [`Status`]
6//! (pass, warn, fail or error), what it observed and expected, a message, and
7//! a sample of the failing rows. [`not_null`] and [`unique`] are two small
8//! built-in checks over [`Rows`], for the common cases.
9//!
10//! [`QualityReport`] shows the failures, errors and warnings first, each
11//! with its observed and expected values and its failing rows as a table,
12//! then a table of every check and a summary line. Names, messages and cells
13//! are shown with terminal controls made visible, so data cannot move the
14//! cursor or recolour the terminal. Statuses take the `quality.*` theme keys
15//! in [`STYLES`].
16//!
17//! ```
18//! use rich::Console;
19//! use rich_data::quality::{self, CheckResult, QualityReport, Status};
20//! use rich_data::{Rows, Value};
21//!
22//! let mut rows = Rows::new(["id", "email"]);
23//! rows.push([Value::Int(1), "ada@example.com".into()]);
24//! rows.push([Value::Int(2), Value::Null]);
25//! rows.push([Value::Int(2), "alan@example.com".into()]);
26//!
27//! let report = QualityReport::new([
28//!     quality::not_null(&rows, "email").unwrap(),
29//!     quality::unique(&rows, "id").unwrap(),
30//!     CheckResult::new("row_count", Status::Warn)
31//!         .observed("3")
32//!         .expected(">= 100"),
33//! ]);
34//! assert_eq!(report.totals().failed, 2);
35//! assert!(!report.is_success());
36//!
37//! let console = Console::builder().width(60).color_system(None).build();
38//! let out = console.render_export(&report);
39//! assert!(out.starts_with("FAIL not_null › email\n  observed 1 null · expected 0\n"));
40//! assert!(out.ends_with("0 passed, 1 warned, 2 failed\n"));
41//! ```
42
43use rich::{Console, ConsoleOptions, Renderable, Segment, Table, Text};
44use rich_ext::sanitize::{sanitize_single_line, sanitize_terminal_and_bidi_controls};
45use serde::{Deserialize, Serialize};
46
47use crate::profile::{block, join, style};
48use crate::{DataError, Rows, Value};
49
50/// The theme keys a quality report draws with, and their defaults. A console
51/// theme that defines a key wins.
52pub const STYLES: &[(&str, &str)] = &[
53    ("quality.pass", "green"),
54    ("quality.warn", "yellow"),
55    ("quality.fail", "bold red"),
56    ("quality.error", "bold magenta"),
57    ("quality.check", "bold"),
58    ("quality.dim", "dim"),
59];
60
61/// How many failing rows a built-in check keeps.
62pub const FAILING_ROWS: usize = 5;
63
64/// How a check ended.
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum Status {
68    /// The data met the expectation.
69    Pass,
70    /// Worth a look, but not a failure.
71    Warn,
72    /// The data did not meet the expectation.
73    Fail,
74    /// The check could not run.
75    Error,
76}
77
78impl Status {
79    /// The label a report writes: `PASS`, `WARN`, `FAIL` or `ERROR`.
80    pub fn label(self) -> &'static str {
81        match self {
82            Status::Pass => "PASS",
83            Status::Warn => "WARN",
84            Status::Fail => "FAIL",
85            Status::Error => "ERROR",
86        }
87    }
88
89    /// The theme key it is drawn in.
90    pub fn key(self) -> &'static str {
91        match self {
92            Status::Pass => "quality.pass",
93            Status::Warn => "quality.warn",
94            Status::Fail => "quality.fail",
95            Status::Error => "quality.error",
96        }
97    }
98}
99
100/// Some of the rows a check failed on.
101#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
102pub struct FailingRows {
103    /// The column headings.
104    pub columns: Vec<String>,
105    /// The rows, as text.
106    pub rows: Vec<Vec<String>>,
107    /// How many rows failed in all, when more than `rows` holds.
108    pub total: Option<u64>,
109}
110
111/// One check's result.
112#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
113pub struct CheckResult {
114    /// The check's name (`not_null`, `unique`, `row_count`, …).
115    pub check: String,
116    /// The column it looked at, if one.
117    pub column: Option<String>,
118    /// How it ended.
119    pub status: Status,
120    /// What it found.
121    pub observed: Option<String>,
122    /// What it wanted.
123    pub expected: Option<String>,
124    /// Anything else to say (an error's cause).
125    pub message: Option<String>,
126    /// A sample of the rows it failed on.
127    pub failing_rows: Option<FailingRows>,
128}
129
130impl CheckResult {
131    /// A result with nothing observed or expected yet.
132    pub fn new(check: impl Into<String>, status: Status) -> Self {
133        CheckResult {
134            check: check.into(),
135            column: None,
136            status,
137            observed: None,
138            expected: None,
139            message: None,
140            failing_rows: None,
141        }
142    }
143
144    /// The column checked.
145    pub fn column(mut self, column: impl Into<String>) -> Self {
146        self.column = Some(column.into());
147        self
148    }
149
150    /// What the check found.
151    pub fn observed(mut self, observed: impl Into<String>) -> Self {
152        self.observed = Some(observed.into());
153        self
154    }
155
156    /// What the check wanted.
157    pub fn expected(mut self, expected: impl Into<String>) -> Self {
158        self.expected = Some(expected.into());
159        self
160    }
161
162    /// A message.
163    pub fn message(mut self, message: impl Into<String>) -> Self {
164        self.message = Some(message.into());
165        self
166    }
167
168    /// A sample of the failing rows.
169    pub fn failing_rows(mut self, rows: FailingRows) -> Self {
170        self.failing_rows = Some(rows);
171        self
172    }
173
174    /// `check › column`, or the check alone.
175    pub fn title(&self) -> String {
176        match &self.column {
177            Some(column) => format!("{} › {}", self.check, column),
178            None => self.check.clone(),
179        }
180    }
181}
182
183/// How many checks ended each way.
184#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
185pub struct Totals {
186    /// Passed.
187    pub passed: usize,
188    /// Warned.
189    pub warned: usize,
190    /// Failed.
191    pub failed: usize,
192    /// Could not run.
193    pub errored: usize,
194}
195
196/// Results as a report: failures first, a table and a summary. See the
197/// [module docs](self).
198#[derive(Clone, Debug, PartialEq)]
199pub struct QualityReport {
200    results: Vec<CheckResult>,
201    show_rows: usize,
202}
203
204impl QualityReport {
205    /// A report of `results`, in order.
206    pub fn new(results: impl IntoIterator<Item = CheckResult>) -> Self {
207        QualityReport {
208            results: results.into_iter().collect(),
209            show_rows: FAILING_ROWS,
210        }
211    }
212
213    /// Add a result.
214    pub fn push(&mut self, result: CheckResult) -> &mut Self {
215        self.results.push(result);
216        self
217    }
218
219    /// The most failing rows shown per check (default [`FAILING_ROWS`]).
220    pub fn show_rows(mut self, rows: usize) -> Self {
221        self.show_rows = rows;
222        self
223    }
224
225    /// The results, in order.
226    pub fn results(&self) -> &[CheckResult] {
227        &self.results
228    }
229
230    /// How many ended each way.
231    pub fn totals(&self) -> Totals {
232        let mut t = Totals::default();
233        for r in &self.results {
234            match r.status {
235                Status::Pass => t.passed += 1,
236                Status::Warn => t.warned += 1,
237                Status::Fail => t.failed += 1,
238                Status::Error => t.errored += 1,
239            }
240        }
241        t
242    }
243
244    /// Whether nothing failed or errored (warnings pass).
245    pub fn is_success(&self) -> bool {
246        let t = self.totals();
247        t.failed == 0 && t.errored == 0
248    }
249
250    /// The summary: `3 passed, 1 warned, 2 failed`, with errors when any.
251    pub fn summary(&self) -> String {
252        let t = self.totals();
253        let mut parts = vec![
254            format!("{} passed", t.passed),
255            format!("{} warned", t.warned),
256            format!("{} failed", t.failed),
257        ];
258        if t.errored > 0 {
259            parts.push(format!("{} errored", t.errored));
260        }
261        parts.join(", ")
262    }
263
264    /// The results as JSON: `results` and `totals`.
265    pub fn to_json(&self) -> serde_json::Value {
266        serde_json::json!({
267            "results": self.results,
268            "totals": self.totals(),
269            "ok": self.is_success(),
270        })
271    }
272
273    /// A table of every check: status, check, column, observed, expected.
274    pub fn to_table(&self, console: &Console) -> Table {
275        let mut table = Table::new();
276        for heading in ["status", "check", "column", "observed", "expected"] {
277            table.add_column(heading);
278        }
279        for r in &self.results {
280            let line =
281                |v: &Option<String>| Text::new(v.as_deref().map(one_line).unwrap_or_default());
282            table.add_row_text(vec![
283                Text::styled(r.status.label(), style(console, r.status.key(), STYLES)),
284                Text::new(one_line(&r.check)),
285                line(&r.column),
286                line(&r.observed),
287                line(&r.expected),
288            ]);
289        }
290        table
291    }
292
293    fn details(
294        &self,
295        console: &Console,
296        options: &ConsoleOptions,
297        r: &CheckResult,
298    ) -> Vec<Vec<Segment>> {
299        let mut lines = Vec::new();
300        let mut title = Text::styled(
301            format!("{} ", r.status.label()),
302            style(console, r.status.key(), STYLES),
303        );
304        title.append(
305            &one_line(&r.title()),
306            Some(style(console, "quality.check", STYLES).into()),
307        );
308        block(console, options, &title, 0, &mut lines);
309        let mut found = Vec::new();
310        if let Some(observed) = &r.observed {
311            found.push(format!("observed {}", one_line(observed)));
312        }
313        if let Some(expected) = &r.expected {
314            found.push(format!("expected {}", one_line(expected)));
315        }
316        if !found.is_empty() {
317            block(
318                console,
319                options,
320                &Text::new(found.join(" · ")),
321                2,
322                &mut lines,
323            );
324        }
325        if let Some(message) = &r.message {
326            let message = sanitize_terminal_and_bidi_controls(message.trim_end());
327            block(console, options, &Text::new(message), 2, &mut lines);
328        }
329        if let Some(failing) = r.failing_rows.as_ref().filter(|f| !f.rows.is_empty()) {
330            let mut table = Table::new();
331            for heading in &failing.columns {
332                table.add_column(one_line(heading));
333            }
334            let width = failing.columns.len().max(1);
335            if failing.columns.is_empty() {
336                table.add_column("");
337            }
338            for row in failing.rows.iter().take(self.show_rows) {
339                let mut cells: Vec<Text> = row
340                    .iter()
341                    .take(width)
342                    .map(|c| Text::new(one_line(c)))
343                    .collect();
344                cells.resize_with(width, || Text::new(""));
345                table.add_row_text(cells);
346            }
347            block(console, options, &table, 2, &mut lines);
348            let shown = failing.rows.len().min(self.show_rows) as u64;
349            let total = failing
350                .total
351                .unwrap_or(failing.rows.len() as u64)
352                .max(shown);
353            if total > shown {
354                let more = Text::styled(
355                    format!("{shown} of {total} failing rows shown"),
356                    style(console, "quality.dim", STYLES),
357                );
358                block(console, options, &more, 2, &mut lines);
359            }
360        }
361        lines
362    }
363}
364
365impl Renderable for QualityReport {
366    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
367        if options.max_width == 0 {
368            return Vec::new();
369        }
370        let options = options.reset_height();
371        let mut lines: Vec<Vec<Segment>> = Vec::new();
372        // Failures and errors first, then warnings.
373        for wanted in [[Status::Fail, Status::Error], [Status::Warn, Status::Warn]] {
374            for r in self.results.iter().filter(|r| wanted.contains(&r.status)) {
375                lines.extend(self.details(console, &options, r));
376                lines.push(Vec::new());
377            }
378        }
379        if !self.results.is_empty() {
380            block(console, &options, &self.to_table(console), 0, &mut lines);
381        }
382        let t = self.totals();
383        let mut total = Text::new("");
384        let mut part = |n: usize, label: &str, status: Status, always: bool| {
385            if n == 0 && !always {
386                return;
387            }
388            if !total.is_empty() {
389                total.append(", ", None);
390            }
391            let st = if n > 0 {
392                Some(style(console, status.key(), STYLES).into())
393            } else {
394                None
395            };
396            total.append(&format!("{n} {label}"), st);
397        };
398        part(t.passed, "passed", Status::Pass, true);
399        part(t.warned, "warned", Status::Warn, true);
400        part(t.failed, "failed", Status::Fail, true);
401        part(t.errored, "errored", Status::Error, false);
402        block(console, &options, &total, 0, &mut lines);
403        join(lines)
404    }
405
406    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
407        let widest = Segment::split_lines(&self.rich_render(console, options))
408            .iter()
409            .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
410            .max()
411            .unwrap_or(0);
412        rich::measure::Measurement::new(widest, widest)
413    }
414}
415
416/// Data shown on one line, controls visible.
417fn one_line(text: &str) -> String {
418    sanitize_single_line(text)
419}
420
421/// `not_null`: every cell of `column` has a value. Nulls are
422/// [`Value::Null`] and empty text. Fails with the first null rows (`row` is
423/// counted from 1); an unknown column is an error.
424pub fn not_null(rows: &Rows, column: &str) -> Result<CheckResult, DataError> {
425    let index = column_index(rows, column)?;
426    let failing: Vec<usize> = rows
427        .rows()
428        .iter()
429        .enumerate()
430        .filter(|(_, row)| row.get(index).is_none_or(Value::is_empty))
431        .map(|(i, _)| i)
432        .collect();
433    let count = failing.len() as u64;
434    let result = CheckResult::new("not_null", status(count))
435        .column(column)
436        .observed(format!(
437            "{count} {}",
438            if count == 1 { "null" } else { "nulls" }
439        ))
440        .expected("0");
441    Ok(with_rows(result, rows, &failing))
442}
443
444/// `unique`: no two non-null cells of `column` hold the same value (by
445/// text). Fails with the rows holding a repeated value, after its first;
446/// an unknown column is an error.
447pub fn unique(rows: &Rows, column: &str) -> Result<CheckResult, DataError> {
448    let index = column_index(rows, column)?;
449    let mut seen = std::collections::HashSet::new();
450    let mut failing = Vec::new();
451    for (i, row) in rows.rows().iter().enumerate() {
452        let Some(value) = row.get(index).filter(|v| !v.is_empty()) else {
453            continue;
454        };
455        if !seen.insert(value.plain()) {
456            failing.push(i);
457        }
458    }
459    let count = failing.len() as u64;
460    let result = CheckResult::new("unique", status(count))
461        .column(column)
462        .observed(format!(
463            "{count} {}",
464            if count == 1 {
465                "duplicate"
466            } else {
467                "duplicates"
468            }
469        ))
470        .expected("0");
471    Ok(with_rows(result, rows, &failing))
472}
473
474fn column_index(rows: &Rows, column: &str) -> Result<usize, DataError> {
475    rows.column_index(column).ok_or_else(|| {
476        DataError::new(format!(
477            "no column {:?}; the columns are {}",
478            one_line(column),
479            rows.columns()
480                .iter()
481                .map(|c| one_line(c))
482                .collect::<Vec<_>>()
483                .join(", ")
484        ))
485    })
486}
487
488fn status(failures: u64) -> Status {
489    if failures == 0 {
490        Status::Pass
491    } else {
492        Status::Fail
493    }
494}
495
496/// Attach the first [`FAILING_ROWS`] of `failing` (indices into `rows`),
497/// each with its row number.
498fn with_rows(result: CheckResult, rows: &Rows, failing: &[usize]) -> CheckResult {
499    if failing.is_empty() {
500        return result;
501    }
502    let mut columns = vec!["row".to_string()];
503    columns.extend(rows.columns().iter().cloned());
504    let sample = failing
505        .iter()
506        .take(FAILING_ROWS)
507        .map(|&i| {
508            let mut cells = vec![(i + 1).to_string()];
509            cells.extend(rows.rows()[i].iter().map(Value::plain));
510            cells
511        })
512        .collect();
513    result.failing_rows(FailingRows {
514        columns,
515        rows: sample,
516        total: Some(failing.len() as u64),
517    })
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    fn people() -> Rows {
525        let mut rows = Rows::new(["id", "name"]);
526        for (id, name) in [(1, "ada"), (2, ""), (2, "alan"), (3, "grace"), (3, "")] {
527            rows.push([Value::Int(id), name.into()]);
528        }
529        rows
530    }
531
532    #[test]
533    fn built_in_checks_find_their_rows() {
534        let rows = people();
535        let nulls = not_null(&rows, "name").unwrap();
536        assert_eq!(nulls.status, Status::Fail);
537        assert_eq!(nulls.observed.as_deref(), Some("2 nulls"));
538        let failing = nulls.failing_rows.unwrap();
539        assert_eq!(failing.columns, ["row", "id", "name"]);
540        assert_eq!(failing.rows, [["2", "2", ""], ["5", "3", ""]]);
541        let dupes = unique(&rows, "id").unwrap();
542        assert_eq!(dupes.observed.as_deref(), Some("2 duplicates"));
543        assert_eq!(dupes.failing_rows.unwrap().rows[0][0], "3");
544        let ok = unique(&rows, "name").unwrap();
545        assert_eq!(ok.status, Status::Pass);
546        assert!(ok.failing_rows.is_none());
547        let error = not_null(&rows, "nope").unwrap_err();
548        assert_eq!(
549            error.to_string(),
550            "no column \"nope\"; the columns are id, name"
551        );
552    }
553
554    #[test]
555    fn the_report_shows_failures_first_then_a_table_and_a_summary() {
556        let rows = people();
557        let report = QualityReport::new([
558            CheckResult::new("row_count", Status::Pass)
559                .observed("5")
560                .expected(">= 1"),
561            not_null(&rows, "name").unwrap(),
562            CheckResult::new("freshness", Status::Error).message("no \x1b[31mclock\x1b[0m"),
563            CheckResult::new("range", Status::Warn)
564                .column("id")
565                .observed("max 3"),
566        ])
567        .show_rows(1);
568        let console = Console::builder().width(60).color_system(None).build();
569        let out = console.render_export(&report);
570        let expected = concat!(
571            "FAIL not_null › name\n",
572            "  observed 2 nulls · expected 0\n",
573            "  ┏━━━━━┳━━━━┳━━━━━━┓\n",
574            "  ┃ row ┃ id ┃ name ┃\n",
575            "  ┡━━━━━╇━━━━╇━━━━━━┩\n",
576            "  │ 2   │ 2  │      │\n",
577            "  └─────┴────┴──────┘\n",
578            "  1 of 2 failing rows shown\n",
579            "\n",
580            "ERROR freshness\n",
581            "  no ␛[31mclock␛[0m\n",
582            "\n",
583            "WARN range › id\n",
584            "  observed max 3\n",
585            "\n",
586            "┏━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┓\n",
587            "┃ status ┃ check     ┃ column ┃ observed ┃ expected ┃\n",
588            "┡━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━┩\n",
589            "│ PASS   │ row_count │        │ 5        │ >= 1     │\n",
590            "│ FAIL   │ not_null  │ name   │ 2 nulls  │ 0        │\n",
591            "│ ERROR  │ freshness │        │          │          │\n",
592            "│ WARN   │ range     │ id     │ max 3    │          │\n",
593            "└────────┴───────────┴────────┴──────────┴──────────┘\n",
594            "1 passed, 1 warned, 1 failed, 1 errored\n",
595        );
596        assert_eq!(out, expected);
597        assert_eq!(report.to_json()["totals"]["errored"], 1);
598        assert_eq!(report.to_json()["results"][1]["status"], "fail");
599    }
600
601    #[test]
602    fn status_colours_come_from_the_theme_keys() {
603        let report = QualityReport::new([CheckResult::new("x", Status::Fail)]);
604        let console = Console::builder()
605            .width(40)
606            .force_terminal(true)
607            .color_system(Some(rich::ColorSystem::Standard))
608            .build();
609        let out = console.render_export(&report);
610        assert!(out.contains("\x1b[1;31mFAIL"), "{out:?}");
611    }
612}