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    #[must_use]
146    pub fn column(mut self, column: impl Into<String>) -> Self {
147        self.column = Some(column.into());
148        self
149    }
150
151    /// What the check found.
152    #[must_use]
153    pub fn observed(mut self, observed: impl Into<String>) -> Self {
154        self.observed = Some(observed.into());
155        self
156    }
157
158    /// What the check wanted.
159    #[must_use]
160    pub fn expected(mut self, expected: impl Into<String>) -> Self {
161        self.expected = Some(expected.into());
162        self
163    }
164
165    /// A message.
166    #[must_use]
167    pub fn message(mut self, message: impl Into<String>) -> Self {
168        self.message = Some(message.into());
169        self
170    }
171
172    /// A sample of the failing rows.
173    #[must_use]
174    pub fn failing_rows(mut self, rows: FailingRows) -> Self {
175        self.failing_rows = Some(rows);
176        self
177    }
178
179    /// `check › column`, or the check alone.
180    pub fn title(&self) -> String {
181        match &self.column {
182            Some(column) => format!("{} › {}", self.check, column),
183            None => self.check.clone(),
184        }
185    }
186}
187
188/// How many checks ended each way.
189#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
190pub struct Totals {
191    /// Passed.
192    pub passed: usize,
193    /// Warned.
194    pub warned: usize,
195    /// Failed.
196    pub failed: usize,
197    /// Could not run.
198    pub errored: usize,
199}
200
201/// Results as a report: failures first, a table and a summary. See the
202/// [module docs](self).
203#[derive(Clone, Debug, PartialEq)]
204pub struct QualityReport {
205    results: Vec<CheckResult>,
206    show_rows: usize,
207}
208
209impl QualityReport {
210    /// A report of `results`, in order.
211    pub fn new(results: impl IntoIterator<Item = CheckResult>) -> Self {
212        QualityReport {
213            results: results.into_iter().collect(),
214            show_rows: FAILING_ROWS,
215        }
216    }
217
218    /// Add a result.
219    pub fn push(&mut self, result: CheckResult) -> &mut Self {
220        self.results.push(result);
221        self
222    }
223
224    /// The most failing rows shown per check (default [`FAILING_ROWS`]).
225    #[must_use]
226    pub fn show_rows(mut self, rows: usize) -> Self {
227        self.show_rows = rows;
228        self
229    }
230
231    /// The results, in order.
232    pub fn results(&self) -> &[CheckResult] {
233        &self.results
234    }
235
236    /// How many ended each way.
237    pub fn totals(&self) -> Totals {
238        let mut t = Totals::default();
239        for r in &self.results {
240            match r.status {
241                Status::Pass => t.passed += 1,
242                Status::Warn => t.warned += 1,
243                Status::Fail => t.failed += 1,
244                Status::Error => t.errored += 1,
245            }
246        }
247        t
248    }
249
250    /// Whether nothing failed or errored (warnings pass).
251    pub fn is_success(&self) -> bool {
252        let t = self.totals();
253        t.failed == 0 && t.errored == 0
254    }
255
256    /// The summary: `3 passed, 1 warned, 2 failed`, with errors when any.
257    pub fn summary(&self) -> String {
258        let t = self.totals();
259        let mut parts = vec![
260            format!("{} passed", t.passed),
261            format!("{} warned", t.warned),
262            format!("{} failed", t.failed),
263        ];
264        if t.errored > 0 {
265            parts.push(format!("{} errored", t.errored));
266        }
267        parts.join(", ")
268    }
269
270    /// The results as JSON: `results` and `totals`.
271    pub fn to_json(&self) -> serde_json::Value {
272        serde_json::json!({
273            "results": self.results,
274            "totals": self.totals(),
275            "ok": self.is_success(),
276        })
277    }
278
279    /// A table of every check: status, check, column, observed, expected.
280    pub fn to_table(&self, console: &Console) -> Table {
281        let mut table = Table::new();
282        for heading in ["status", "check", "column", "observed", "expected"] {
283            table.add_column(heading);
284        }
285        for r in &self.results {
286            let line =
287                |v: &Option<String>| Text::new(v.as_deref().map(one_line).unwrap_or_default());
288            table.add_row_text(vec![
289                Text::styled(r.status.label(), style(console, r.status.key(), STYLES)),
290                Text::new(one_line(&r.check)),
291                line(&r.column),
292                line(&r.observed),
293                line(&r.expected),
294            ]);
295        }
296        table
297    }
298
299    fn details(
300        &self,
301        console: &Console,
302        options: &ConsoleOptions,
303        r: &CheckResult,
304    ) -> Vec<Vec<Segment>> {
305        let mut lines = Vec::new();
306        let mut title = Text::styled(
307            format!("{} ", r.status.label()),
308            style(console, r.status.key(), STYLES),
309        );
310        title.append(
311            &one_line(&r.title()),
312            Some(style(console, "quality.check", STYLES).into()),
313        );
314        block(console, options, &title, 0, &mut lines);
315        let mut found = Vec::new();
316        if let Some(observed) = &r.observed {
317            found.push(format!("observed {}", one_line(observed)));
318        }
319        if let Some(expected) = &r.expected {
320            found.push(format!("expected {}", one_line(expected)));
321        }
322        if !found.is_empty() {
323            block(
324                console,
325                options,
326                &Text::new(found.join(" · ")),
327                2,
328                &mut lines,
329            );
330        }
331        if let Some(message) = &r.message {
332            let message = sanitize_terminal_and_bidi_controls(message.trim_end());
333            block(console, options, &Text::new(message), 2, &mut lines);
334        }
335        if let Some(failing) = r.failing_rows.as_ref().filter(|f| !f.rows.is_empty()) {
336            let mut table = Table::new();
337            for heading in &failing.columns {
338                table.add_column(one_line(heading));
339            }
340            let width = failing.columns.len().max(1);
341            if failing.columns.is_empty() {
342                table.add_column("");
343            }
344            for row in failing.rows.iter().take(self.show_rows) {
345                let mut cells: Vec<Text> = row
346                    .iter()
347                    .take(width)
348                    .map(|c| Text::new(one_line(c)))
349                    .collect();
350                cells.resize_with(width, || Text::new(""));
351                table.add_row_text(cells);
352            }
353            block(console, options, &table, 2, &mut lines);
354            let shown = failing.rows.len().min(self.show_rows) as u64;
355            let total = failing
356                .total
357                .unwrap_or(failing.rows.len() as u64)
358                .max(shown);
359            if total > shown {
360                let more = Text::styled(
361                    format!("{shown} of {total} failing rows shown"),
362                    style(console, "quality.dim", STYLES),
363                );
364                block(console, options, &more, 2, &mut lines);
365            }
366        }
367        lines
368    }
369}
370
371impl Renderable for QualityReport {
372    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
373        if options.max_width == 0 {
374            return Vec::new();
375        }
376        let options = options.reset_height();
377        let mut lines: Vec<Vec<Segment>> = Vec::new();
378        // Failures and errors first, then warnings.
379        for wanted in [[Status::Fail, Status::Error], [Status::Warn, Status::Warn]] {
380            for r in self.results.iter().filter(|r| wanted.contains(&r.status)) {
381                lines.extend(self.details(console, &options, r));
382                lines.push(Vec::new());
383            }
384        }
385        if !self.results.is_empty() {
386            block(console, &options, &self.to_table(console), 0, &mut lines);
387        }
388        let t = self.totals();
389        let mut total = Text::new("");
390        let mut part = |n: usize, label: &str, status: Status, always: bool| {
391            if n == 0 && !always {
392                return;
393            }
394            if !total.is_empty() {
395                total.append(", ", None);
396            }
397            let st = if n > 0 {
398                Some(style(console, status.key(), STYLES).into())
399            } else {
400                None
401            };
402            total.append(&format!("{n} {label}"), st);
403        };
404        part(t.passed, "passed", Status::Pass, true);
405        part(t.warned, "warned", Status::Warn, true);
406        part(t.failed, "failed", Status::Fail, true);
407        part(t.errored, "errored", Status::Error, false);
408        block(console, &options, &total, 0, &mut lines);
409        join(lines)
410    }
411
412    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
413        let widest = Segment::split_lines(&self.rich_render(console, options))
414            .iter()
415            .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
416            .max()
417            .unwrap_or(0);
418        rich::measure::Measurement::new(widest, widest)
419    }
420}
421
422/// Data shown on one line, controls visible.
423fn one_line(text: &str) -> String {
424    sanitize_single_line(text)
425}
426
427/// `not_null`: every cell of `column` has a value. Nulls are
428/// [`Value::Null`] and empty text. Fails with the first null rows (`row` is
429/// counted from 1); an unknown column is an error.
430pub fn not_null(rows: &Rows, column: &str) -> Result<CheckResult, DataError> {
431    let index = column_index(rows, column)?;
432    let failing: Vec<usize> = rows
433        .rows()
434        .iter()
435        .enumerate()
436        .filter(|(_, row)| row.get(index).is_none_or(Value::is_empty))
437        .map(|(i, _)| i)
438        .collect();
439    let count = failing.len() as u64;
440    let result = CheckResult::new("not_null", status(count))
441        .column(column)
442        .observed(format!(
443            "{count} {}",
444            if count == 1 { "null" } else { "nulls" }
445        ))
446        .expected("0");
447    Ok(with_rows(result, rows, &failing))
448}
449
450/// `unique`: no two non-null cells of `column` hold the same value (by
451/// text). Fails with the rows holding a repeated value, after its first;
452/// an unknown column is an error.
453pub fn unique(rows: &Rows, column: &str) -> Result<CheckResult, DataError> {
454    let index = column_index(rows, column)?;
455    let mut seen = std::collections::HashSet::new();
456    let mut failing = Vec::new();
457    for (i, row) in rows.rows().iter().enumerate() {
458        let Some(value) = row.get(index).filter(|v| !v.is_empty()) else {
459            continue;
460        };
461        if !seen.insert(value.plain()) {
462            failing.push(i);
463        }
464    }
465    let count = failing.len() as u64;
466    let result = CheckResult::new("unique", status(count))
467        .column(column)
468        .observed(format!(
469            "{count} {}",
470            if count == 1 {
471                "duplicate"
472            } else {
473                "duplicates"
474            }
475        ))
476        .expected("0");
477    Ok(with_rows(result, rows, &failing))
478}
479
480fn column_index(rows: &Rows, column: &str) -> Result<usize, DataError> {
481    rows.column_index(column).ok_or_else(|| {
482        DataError::new(format!(
483            "no column {:?}; the columns are {}",
484            one_line(column),
485            rows.columns()
486                .iter()
487                .map(|c| one_line(c))
488                .collect::<Vec<_>>()
489                .join(", ")
490        ))
491    })
492}
493
494fn status(failures: u64) -> Status {
495    if failures == 0 {
496        Status::Pass
497    } else {
498        Status::Fail
499    }
500}
501
502/// Attach the first [`FAILING_ROWS`] of `failing` (indices into `rows`),
503/// each with its row number.
504fn with_rows(result: CheckResult, rows: &Rows, failing: &[usize]) -> CheckResult {
505    if failing.is_empty() {
506        return result;
507    }
508    let mut columns = vec!["row".to_string()];
509    columns.extend(rows.columns().iter().cloned());
510    let sample = failing
511        .iter()
512        .take(FAILING_ROWS)
513        .map(|&i| {
514            let mut cells = vec![(i + 1).to_string()];
515            cells.extend(rows.rows()[i].iter().map(Value::plain));
516            cells
517        })
518        .collect();
519    result.failing_rows(FailingRows {
520        columns,
521        rows: sample,
522        total: Some(failing.len() as u64),
523    })
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    fn people() -> Rows {
531        let mut rows = Rows::new(["id", "name"]);
532        for (id, name) in [(1, "ada"), (2, ""), (2, "alan"), (3, "grace"), (3, "")] {
533            rows.push([Value::Int(id), name.into()]);
534        }
535        rows
536    }
537
538    #[test]
539    fn built_in_checks_find_their_rows() {
540        let rows = people();
541        let nulls = not_null(&rows, "name").unwrap();
542        assert_eq!(nulls.status, Status::Fail);
543        assert_eq!(nulls.observed.as_deref(), Some("2 nulls"));
544        let failing = nulls.failing_rows.unwrap();
545        assert_eq!(failing.columns, ["row", "id", "name"]);
546        assert_eq!(failing.rows, [["2", "2", ""], ["5", "3", ""]]);
547        let dupes = unique(&rows, "id").unwrap();
548        assert_eq!(dupes.observed.as_deref(), Some("2 duplicates"));
549        assert_eq!(dupes.failing_rows.unwrap().rows[0][0], "3");
550        let ok = unique(&rows, "name").unwrap();
551        assert_eq!(ok.status, Status::Pass);
552        assert!(ok.failing_rows.is_none());
553        let error = not_null(&rows, "nope").unwrap_err();
554        assert_eq!(
555            error.to_string(),
556            "no column \"nope\"; the columns are id, name"
557        );
558    }
559
560    #[test]
561    fn the_report_shows_failures_first_then_a_table_and_a_summary() {
562        let rows = people();
563        let report = QualityReport::new([
564            CheckResult::new("row_count", Status::Pass)
565                .observed("5")
566                .expected(">= 1"),
567            not_null(&rows, "name").unwrap(),
568            CheckResult::new("freshness", Status::Error).message("no \x1b[31mclock\x1b[0m"),
569            CheckResult::new("range", Status::Warn)
570                .column("id")
571                .observed("max 3"),
572        ])
573        .show_rows(1);
574        let console = Console::builder().width(60).color_system(None).build();
575        let out = console.render_export(&report);
576        let expected = concat!(
577            "FAIL not_null › name\n",
578            "  observed 2 nulls · expected 0\n",
579            "  ┏━━━━━┳━━━━┳━━━━━━┓\n",
580            "  ┃ row ┃ id ┃ name ┃\n",
581            "  ┡━━━━━╇━━━━╇━━━━━━┩\n",
582            "  │ 2   │ 2  │      │\n",
583            "  └─────┴────┴──────┘\n",
584            "  1 of 2 failing rows shown\n",
585            "\n",
586            "ERROR freshness\n",
587            "  no ␛[31mclock␛[0m\n",
588            "\n",
589            "WARN range › id\n",
590            "  observed max 3\n",
591            "\n",
592            "┏━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┓\n",
593            "┃ status ┃ check     ┃ column ┃ observed ┃ expected ┃\n",
594            "┡━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━┩\n",
595            "│ PASS   │ row_count │        │ 5        │ >= 1     │\n",
596            "│ FAIL   │ not_null  │ name   │ 2 nulls  │ 0        │\n",
597            "│ ERROR  │ freshness │        │          │          │\n",
598            "│ WARN   │ range     │ id     │ max 3    │          │\n",
599            "└────────┴───────────┴────────┴──────────┴──────────┘\n",
600            "1 passed, 1 warned, 1 failed, 1 errored\n",
601        );
602        assert_eq!(out, expected);
603        assert_eq!(report.to_json()["totals"]["errored"], 1);
604        assert_eq!(report.to_json()["results"][1]["status"], "fail");
605    }
606
607    #[test]
608    fn status_colours_come_from_the_theme_keys() {
609        let report = QualityReport::new([CheckResult::new("x", Status::Fail)]);
610        let console = Console::builder()
611            .width(40)
612            .force_terminal(true)
613            .color_system(Some(rich::ColorSystem::Standard))
614            .build();
615        let out = console.render_export(&report);
616        assert!(out.contains("\x1b[1;31mFAIL"), "{out:?}");
617    }
618}