1use rich::{
68 Cell, ColumnOptions, Console, ConsoleOptions, Justify, Renderable, Segment, Style, Table, Text,
69};
70use rich_ext::chart::{BarChart, Heatmap, Histogram};
71use rich_ext::sanitize::sanitize_single_line;
72use rich_ext::table::StyleRules;
73use serde::{Deserialize, Serialize};
74
75use crate::infer::{DEFAULT_NULL_TOKENS, Inferrer, parse_float};
76use crate::stats::{ColumnStats, Stats, StatsOptions, number};
77use crate::{DataError, Row, RowSource, Rows, Value};
78
79pub mod nested;
80
81pub use nested::{
82 DEFAULT_DEPTH, DEFAULT_MAX_PATHS, JsonProfiler, MAX_MALFORMED_LINES, PathSummary, TypeCounts,
83 path_key,
84};
85
86pub const DEFAULT_SAMPLE: usize = 10_000;
88pub const DEFAULT_TOP: usize = 5;
90pub const DEFAULT_BINS: usize = 10;
92pub const DEFAULT_BUCKETS: usize = 20;
94const LABEL_CHARS: usize = 24;
96const BAR_WIDTH: usize = 24;
98
99pub const STYLES: &[(&str, &str)] = &[
102 ("profile.title", "bold"),
103 ("profile.column", "bold"),
104 ("profile.type", "cyan"),
105 ("profile.note", "dim"),
106 ("profile.null", "yellow"),
107 ("profile.mixed", "magenta"),
108];
109
110pub(crate) fn style(console: &Console, key: &str, styles: &[(&str, &str)]) -> Style {
112 if let Some(style) = console.theme().get(key) {
113 return style.clone();
114 }
115 styles
116 .iter()
117 .find(|(name, _)| *name == key)
118 .and_then(|(_, spec)| Style::parse(spec).ok())
119 .unwrap_or_default()
120}
121
122#[derive(Clone, Debug, PartialEq)]
124pub struct ProfileOptions {
125 pub sample: usize,
128 pub top: usize,
131 pub bins: usize,
133 pub buckets: usize,
136 pub columns: Option<Vec<String>>,
138 pub nulls: Vec<String>,
142 pub depth: usize,
145 pub max_paths: usize,
148}
149
150impl Default for ProfileOptions {
151 fn default() -> Self {
152 ProfileOptions {
153 sample: DEFAULT_SAMPLE,
154 top: DEFAULT_TOP,
155 bins: DEFAULT_BINS,
156 buckets: DEFAULT_BUCKETS,
157 columns: None,
158 nulls: DEFAULT_NULL_TOKENS.map(String::from).to_vec(),
159 depth: DEFAULT_DEPTH,
160 max_paths: DEFAULT_MAX_PATHS,
161 }
162 }
163}
164
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
167pub struct ColumnProfile {
168 pub name: String,
170 #[serde(rename = "type")]
173 pub data_type: String,
174 pub nulls: u64,
176 pub null_rate: f64,
178 pub count: usize,
180 pub distinct: usize,
182 pub min: Option<String>,
184 pub max: Option<String>,
186 pub mean: Option<f64>,
188 pub median: Option<f64>,
190 pub p25: Option<f64>,
192 pub p75: Option<f64>,
194 pub distribution: Distribution,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub present: Option<u64>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub absent: Option<u64>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub values: Option<u64>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub types: Option<TypeCounts>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub mixed: Option<bool>,
214}
215
216impl ColumnProfile {
217 pub fn absent_rate(&self, rows: u64) -> Option<f64> {
219 self.absent.map(|absent| {
220 if rows == 0 {
221 0.0
222 } else {
223 absent as f64 / rows as f64
224 }
225 })
226 }
227}
228
229#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
231#[serde(tag = "kind", rename_all = "snake_case")]
232pub enum Distribution {
233 Histogram {
235 bins: Vec<Bin>,
237 },
238 Top {
240 values: Vec<TopValue>,
242 other: usize,
244 },
245 Empty,
247}
248
249#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
251pub struct Bin {
252 pub label: String,
254 pub low: f64,
256 pub high: f64,
258 pub count: usize,
260}
261
262#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
264pub struct TopValue {
265 pub value: String,
267 pub count: usize,
269}
270
271#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
274pub struct MissingMap {
275 pub bucket_rows: u64,
277 pub buckets: Vec<MissingBucket>,
279}
280
281#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
283pub struct MissingBucket {
284 pub first: u64,
286 pub rows: u64,
288 pub nulls: Vec<u64>,
290}
291
292#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
294pub struct Profile {
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub name: Option<String>,
298 rows: u64,
299 sample: usize,
300 columns: Vec<ColumnProfile>,
301 missing: MissingMap,
302 #[serde(default, skip_serializing_if = "Vec::is_empty")]
303 notes: Vec<String>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
307 paths: Option<PathSummary>,
308 #[serde(default, skip_serializing_if = "is_zero")]
310 malformed: u64,
311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 malformed_lines: Vec<usize>,
314 #[serde(skip)]
316 rules: Rules,
317}
318
319fn is_zero(n: &u64) -> bool {
320 *n == 0
321}
322
323#[derive(Clone, Debug, Default)]
326struct Rules(StyleRules);
327
328impl PartialEq for Rules {
329 fn eq(&self, _: &Self) -> bool {
330 true
331 }
332}
333
334impl Profile {
335 pub fn of(rows: &Rows) -> Self {
337 let mut profiler = Profiler::new(rows.columns(), ProfileOptions::default())
338 .expect("every column of the rows exists");
339 for row in rows.rows() {
340 profiler.push(row.clone());
341 }
342 profiler.finish()
343 }
344
345 pub fn from_source(
348 mut source: impl RowSource,
349 options: ProfileOptions,
350 ) -> Result<Self, DataError> {
351 let mut profiler = Profiler::new(source.columns(), options)?;
352 while let Some(row) = source.next_row() {
353 profiler.push(row?);
354 }
355 Ok(profiler.finish())
356 }
357
358 #[must_use]
360 pub fn with_name(mut self, name: impl Into<String>) -> Self {
361 self.name = Some(name.into());
362 self
363 }
364
365 pub fn note(&mut self, note: impl Into<String>) {
367 self.notes.push(note.into());
368 }
369
370 pub fn rows(&self) -> u64 {
372 self.rows
373 }
374
375 pub fn sample_size(&self) -> usize {
377 self.sample
378 }
379
380 pub fn sampled(&self) -> bool {
384 match &self.paths {
385 Some(paths) => paths.sampled,
386 None => (self.sample as u64) < self.rows,
387 }
388 }
389
390 pub fn columns(&self) -> &[ColumnProfile] {
392 &self.columns
393 }
394
395 pub fn column(&self, name: &str) -> Option<&ColumnProfile> {
397 self.columns.iter().find(|c| c.name == name)
398 }
399
400 pub fn missing(&self) -> &MissingMap {
402 &self.missing
403 }
404
405 pub fn notes(&self) -> &[String] {
407 &self.notes
408 }
409
410 pub fn is_nested(&self) -> bool {
413 self.paths.is_some()
414 }
415
416 pub fn paths(&self) -> Option<&PathSummary> {
419 self.paths.as_ref()
420 }
421
422 pub fn malformed(&self) -> u64 {
424 self.malformed
425 }
426
427 pub fn malformed_lines(&self) -> &[usize] {
430 &self.malformed_lines
431 }
432
433 #[must_use]
454 pub fn style_rules(mut self, rules: StyleRules) -> Self {
455 self.rules = Rules(rules);
456 self
457 }
458
459 pub fn rules(&self) -> &StyleRules {
461 &self.rules.0
462 }
463
464 pub fn table_headers(&self) -> &'static [&'static str] {
469 if self.is_nested() {
470 &["path", "types", "absent", "distinct", "min", "max", "mean"]
471 } else {
472 &["column", "type", "nulls", "distinct", "min", "max", "mean"]
473 }
474 }
475
476 fn rule_values(&self, c: &ColumnProfile) -> Vec<Value> {
479 let text = |v: &Option<String>| v.clone().map_or(Value::Null, Value::Str);
480 let count = |n: u64| Value::Int(i64::try_from(n).unwrap_or(i64::MAX));
481 let mut values = vec![Value::Str(c.name.clone())];
482 if self.is_nested() {
483 values.push(Value::Str(c.types.map(|t| t.mix()).unwrap_or_default()));
484 values.push(count(c.absent.unwrap_or(0)));
485 } else {
486 values.push(Value::Str(c.data_type.clone()));
487 values.push(count(c.nulls));
488 }
489 values.extend([
490 count(c.distinct as u64),
491 text(&c.min),
492 text(&c.max),
493 c.mean.map_or(Value::Null, Value::Float),
494 ]);
495 values
496 }
497
498 pub fn to_json(&self) -> serde_json::Value {
505 let mut value = serde_json::to_value(self).unwrap_or_default();
506 if let Some(object) = value.as_object_mut() {
507 object.insert("sampled".into(), self.sampled().into());
508 }
509 value
510 }
511
512 pub fn heading(&self) -> String {
515 let count = self.columns.len() as u64;
516 let body = if self.is_nested() {
517 let mut body = format!(
519 "{} {}, {} {}",
520 grouped(self.rows),
521 plural(self.rows, "record", "records"),
522 grouped(count),
523 plural(count, "path", "paths")
524 );
525 if self.sampled() {
526 body.push_str(&format!(
527 ", sampled to {} values a path",
528 grouped(self.sample as u64)
529 ));
530 }
531 body
532 } else {
533 let rows = if self.sampled() {
534 format!(
535 "sampled {} of {} rows",
536 grouped(self.sample as u64),
537 grouped(self.rows)
538 )
539 } else {
540 format!(
541 "{} {}",
542 grouped(self.rows),
543 plural(self.rows, "row", "rows")
544 )
545 };
546 format!("{rows}, {count} {}", plural(count, "column", "columns"))
547 };
548 match &self.name {
549 Some(name) => format!("{}: {body}", sanitize_single_line(name)),
550 None => body,
551 }
552 }
553
554 pub fn to_table(&self) -> Table {
563 self.table(None, Hold::NamesAndMixes)
564 }
565
566 fn table(&self, console: Option<&Console>, hold: Hold) -> Table {
569 let names_first = hold != Hold::Nothing;
570 let mut table = Table::new();
571 let headers = self.table_headers();
572 let widest = self
574 .columns
575 .iter()
576 .map(|c| c.name.chars().count())
577 .max()
578 .unwrap_or(0);
579 let nested_names = names_first && self.is_nested();
582 table.add_column_with(
583 Text::new(headers[0]),
584 ColumnOptions {
585 min_width: names_first.then(|| widest.clamp(6, if nested_names { 24 } else { 16 })),
586 no_wrap: nested_names,
587 ..ColumnOptions::default()
588 },
589 );
590 let mixes = hold == Hold::NamesAndMixes && self.is_nested();
592 table.add_column_with(
593 Text::new(headers[1]),
594 ColumnOptions {
595 no_wrap: mixes,
596 ..ColumnOptions::default()
597 },
598 );
599 for heading in &headers[2..] {
600 table.add_column_justify(*heading, Justify::Right);
601 }
602 let mixed = console.map(|console| style(console, "profile.mixed", STYLES));
603 let rules = &self.rules.0;
604 let values: Vec<Vec<Value>> = if rules.is_empty() {
605 Vec::new()
606 } else {
607 self.columns.iter().map(|c| self.rule_values(c)).collect()
608 };
609 let resolved = (!rules.is_empty()).then(|| rules.resolve(headers, &values));
610 for (index, c) in self.columns.iter().enumerate() {
611 let optional = |v: Option<f64>| v.map(number).unwrap_or_default();
612 let text = |v: &Option<String>| v.as_deref().map(label).unwrap_or_default();
613 let mut cells = vec![Text::new(sanitize_single_line(&c.name))];
614 if self.is_nested() {
615 let mix = c.types.unwrap_or_default().mix();
616 cells.push(match (c.mixed == Some(true), &mixed) {
617 (true, Some(style)) => Text::styled(format!("! {mix}"), style.clone()),
618 (true, None) => Text::new(format!("! {mix}")),
619 (false, _) => Text::new(mix),
620 });
621 cells.push(Text::new(nulls(
622 c.absent.unwrap_or(0),
623 c.absent_rate(self.rows).unwrap_or(0.0),
624 )));
625 } else {
626 cells.push(Text::new(c.data_type.clone()));
627 cells.push(Text::new(nulls(c.nulls, c.null_rate)));
629 }
630 let types = c.types.unwrap_or_default();
632 let container = c.count == 0 && types.object + types.array > 0;
633 cells.extend(
634 [
635 if container {
636 String::new()
637 } else {
638 c.distinct.to_string()
639 },
640 text(&c.min),
641 text(&c.max),
642 optional(c.mean),
643 ]
644 .into_iter()
645 .map(Text::new),
646 );
647 let (Some(resolved), Some(row)) = (&resolved, values.get(index)) else {
648 table.add_row_text(cells);
649 continue;
650 };
651 let cells = cells
652 .into_iter()
653 .enumerate()
654 .map(|(column, mut text)| {
655 if let Some(style) = resolved.cell_style(row, column) {
656 let len = text.plain().len();
657 text.stylize(style, 0, len);
658 }
659 Cell::Text(text)
660 })
661 .collect();
662 table.add_row_with(cells, resolved.row_style(row).map(Into::into), false);
663 }
664 if let Some(paths) = self.paths.as_ref().filter(|p| p.capped) {
666 let more = paths.dropped.len() as u64;
667 let count = if paths.more_dropped {
668 format!("{}+", grouped(more))
669 } else {
670 grouped(more)
671 };
672 let note = format!(
673 "{count} more {} not profiled (max_paths {})",
674 plural(more, "path", "paths"),
675 grouped(paths.max_paths as u64)
676 );
677 let dim = console.map(|console| style(console, "profile.note", STYLES));
678 let cell = |text: String| match &dim {
679 Some(style) => Text::styled(text, style.clone()),
680 None => Text::new(text),
681 };
682 table.add_row_text(vec![cell("…".into()), cell(note)]);
683 }
684 table
685 }
686
687 pub fn missing_heatmap(&self) -> Option<Heatmap> {
695 if !self.is_nested() && self.columns.iter().all(|c| c.nulls == 0) {
696 return None;
697 }
698 let missing = |i: usize| {
699 self.missing
700 .buckets
701 .iter()
702 .any(|b| b.nulls.get(i).is_some_and(|&n| n > 0))
703 };
704 let shown: Vec<usize> = (0..self.columns.len())
706 .filter(|&i| !self.is_nested() || missing(i))
707 .collect();
708 if shown.is_empty() {
709 return None;
710 }
711 let widest = shown
712 .iter()
713 .map(|&i| self.columns[i].name.chars().count())
714 .max()
715 .unwrap_or(1);
716 let mut map = Heatmap::new()
717 .columns(
718 shown
719 .iter()
720 .map(|&i| sanitize_single_line(&self.columns[i].name)),
721 )
722 .range(0.0, 100.0)
723 .cell_width((widest + 1).clamp(2, 12));
724 for bucket in &self.missing.buckets {
725 let last = bucket.first + bucket.rows.saturating_sub(1);
726 let label = if bucket.rows <= 1 {
727 bucket.first.to_string()
728 } else {
729 format!("{}-{last}", bucket.first)
730 };
731 let rows = bucket.rows.max(1) as f64;
732 let share =
733 |&i: &usize| bucket.nulls.get(i).copied().unwrap_or(0) as f64 * 100.0 / rows;
734 map = map.row(label, shown.iter().map(share));
735 }
736 Some(map)
737 }
738}
739
740#[derive(Clone, Copy, PartialEq, Eq)]
742enum Hold {
743 NamesAndMixes,
745 Names,
747 Nothing,
749}
750
751fn distribution_chart(distribution: &Distribution) -> Option<BarChart> {
754 let bars: Vec<(String, f64)> = match distribution {
755 Distribution::Histogram { bins } => bins
756 .iter()
757 .map(|b| (b.label.clone(), b.count as f64))
758 .collect(),
759 Distribution::Top { values, .. } => values
760 .iter()
761 .map(|v| (label(&v.value), v.count as f64))
762 .collect(),
763 Distribution::Empty => return None,
764 };
765 Some(BarChart::from_pairs(bars).bar_width(BAR_WIDTH))
766}
767
768impl Renderable for Profile {
769 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
770 let width = options.max_width;
771 if width == 0 {
772 return Vec::new();
773 }
774 let options = options.reset_height();
775 let note = style(console, "profile.note", STYLES);
776 let mut lines: Vec<Vec<Segment>> = Vec::new();
777 let text = |text: Text, indent: usize, lines: &mut Vec<Vec<Segment>>| {
778 block(console, &options, &text, indent, lines);
779 };
780
781 text(
782 Text::styled(self.heading(), style(console, "profile.title", STYLES)),
783 0,
784 &mut lines,
785 );
786 if self.sampled() {
787 let says = if self.is_nested() {
788 "types, distinct values, statistics and distributions are of a uniform sample \
789 of each path's values; presence, type mixes, nulls and the missing-value map \
790 count every record"
791 } else {
792 "types, distinct values, statistics and distributions are of a uniform sample; \
793 nulls and the missing-value map count every row"
794 };
795 text(Text::styled(says, note.clone()), 0, &mut lines);
796 }
797 for line in &self.notes {
798 text(
799 Text::styled(sanitize_single_line(line), note.clone()),
800 0,
801 &mut lines,
802 );
803 }
804 if self.columns.is_empty() {
805 return join(lines);
806 }
807 lines.push(Vec::new());
808 let fits = |table: &Table| {
813 let drawn = console.render(table, Some(&options));
814 let lines = Segment::split_lines(&drawn);
815 let within = lines.iter().all(|line| {
816 line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
817 });
818 let headings = || {
819 let heading: String = lines
820 .get(1)
821 .map(|line| line.iter().map(|s| s.text.to_string()).collect())
822 .unwrap_or_default();
823 self.table_headers().iter().all(|h| heading.contains(h))
824 };
825 within && (!self.is_nested() || headings())
826 };
827 let mut table = self.table(Some(console), Hold::NamesAndMixes);
828 let mut fit = fits(&table);
829 if self.is_nested() && !fit {
830 let names = self
833 .columns
834 .iter()
835 .map(|c| c.name.chars().count().min(24))
836 .max()
837 .unwrap_or(0);
838 if options.max_width >= names + 3 + 48 {
839 table = self.table(Some(console), Hold::Names);
840 let drawn = console.render(&table, Some(&options));
841 fit = Segment::split_lines(&drawn).iter().all(|line| {
842 line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
843 });
844 }
845 }
846 if !fit {
847 table = self.table(Some(console), Hold::Nothing);
848 }
849 block(console, &options, &table, 0, &mut lines);
850
851 for c in &self.columns {
852 lines.push(Vec::new());
853 let mut title = Text::styled(
854 sanitize_single_line(&c.name),
855 style(console, "profile.column", STYLES),
856 );
857 title.append(" · ", None);
858 title.append(
859 &c.data_type,
860 Some(style(console, "profile.type", STYLES).into()),
861 );
862 let types = c.types.unwrap_or_default();
865 if c.mixed == Some(true) {
866 title.append(" · ", None);
867 title.append(
868 &format!("mixed types: {}", types.mix()),
869 Some(style(console, "profile.mixed", STYLES).into()),
870 );
871 }
872 if let Some(absent) = c.absent.filter(|&n| n > 0) {
873 title.append(" · ", None);
874 title.append(
875 &format!(
876 "{} absent ({})",
877 grouped(absent),
878 percent(c.absent_rate(self.rows).unwrap_or(0.0))
879 ),
880 Some(style(console, "profile.null", STYLES).into()),
881 );
882 }
883 if c.nulls > 0 {
884 title.append(" · ", None);
885 title.append(
886 &format!(
887 "{} {} ({})",
888 grouped(c.nulls),
889 plural(c.nulls, "null", "nulls"),
890 percent(c.null_rate)
891 ),
892 Some(style(console, "profile.null", STYLES).into()),
893 );
894 }
895 let container = c.count == 0 && types.object + types.array > 0;
898 if matches!(c.distribution, Distribution::Histogram { .. }) {
899 let quartiles = [c.p25, c.median, c.p75].map(|q| q.map(number));
900 if let [Some(p25), Some(median), Some(p75)] = quartiles {
901 title.append(&format!(" · median {median} (p25 {p25}, p75 {p75})"), None);
902 }
903 } else if !container {
904 title.append(&format!(" · {} distinct", grouped(c.distinct as u64)), None);
905 }
906 text(title, 0, &mut lines);
907 match distribution_chart(&c.distribution) {
908 Some(chart) => block(console, &options, &chart, 2, &mut lines),
909 None if container => {}
910 None => text(Text::styled("no values", note.clone()), 2, &mut lines),
911 }
912 if let Distribution::Top { values, other } = &c.distribution
913 && *other > 0
914 {
915 let more = c.distinct.saturating_sub(values.len()) as u64;
916 text(
917 Text::styled(
918 format!(
919 "+ {} other {} ({} {})",
920 grouped(more),
921 plural(more, "value", "values"),
922 grouped(*other as u64),
923 plural(*other as u64, "cell", "cells"),
924 ),
925 note.clone(),
926 ),
927 2,
928 &mut lines,
929 );
930 }
931 }
932
933 lines.push(Vec::new());
934 match self.missing_heatmap() {
935 None => text(
936 Text::styled("no missing values", note.clone()),
937 0,
938 &mut lines,
939 ),
940 Some(map) => {
941 let rows = self.missing.bucket_rows;
942 let (what, row, rows_name) = if self.is_nested() {
943 ("% of records absent or null", "a record", "records")
944 } else {
945 ("% null", "a row", "rows")
946 };
947 let per = if rows <= 1 {
948 format!("{row} each")
949 } else {
950 format!("{} {rows_name} each", grouped(rows))
951 };
952 text(
953 Text::styled(
954 format!("missing values ({what}; {rows_name} top to bottom, {per})"),
955 style(console, "profile.column", STYLES),
956 ),
957 0,
958 &mut lines,
959 );
960 block(console, &options, &map, 0, &mut lines);
961 }
962 }
963 join(lines)
964 }
965
966 fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
968 let widest = Segment::split_lines(&self.rich_render(console, options))
969 .iter()
970 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
971 .max()
972 .unwrap_or(0);
973 rich::measure::Measurement::new(widest, widest)
974 }
975}
976
977pub(crate) fn block(
979 console: &Console,
980 options: &ConsoleOptions,
981 renderable: &dyn Renderable,
982 indent: usize,
983 lines: &mut Vec<Vec<Segment>>,
984) {
985 let width = options.max_width.saturating_sub(indent).max(1);
986 let rendered = console.render(renderable, Some(&options.update_width(width)));
987 for line in Segment::split_lines(&rendered) {
988 let mut row = Vec::with_capacity(line.len() + 1);
989 if indent > 0 {
990 row.push(Segment::new(" ".repeat(indent), None));
991 }
992 row.extend(line);
993 lines.push(row);
994 }
995}
996
997pub(crate) fn join(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
999 let mut out = Vec::new();
1000 for (i, line) in lines.into_iter().enumerate() {
1001 if i > 0 {
1002 out.push(Segment::line());
1003 }
1004 out.extend(line);
1005 }
1006 out
1007}
1008
1009#[derive(Clone, Debug)]
1027pub struct Profiler {
1028 options: ProfileOptions,
1029 names: Vec<String>,
1030 indices: Vec<usize>,
1032 rows: u64,
1033 reservoir: Vec<Row>,
1034 rng: u64,
1035 nulls: Vec<u64>,
1036 bucket_rows: u64,
1037 buckets: Vec<(u64, Vec<u64>)>,
1038}
1039
1040impl Profiler {
1041 pub fn new(columns: &[String], options: ProfileOptions) -> Result<Self, DataError> {
1044 let indices: Vec<usize> = match &options.columns {
1045 None => (0..columns.len()).collect(),
1046 Some(wanted) => wanted
1047 .iter()
1048 .map(|name| {
1049 columns.iter().position(|c| c == name).ok_or_else(|| {
1050 DataError::new(format!(
1051 "no column {:?}; the columns are {}",
1052 sanitize_single_line(name),
1053 columns
1054 .iter()
1055 .map(|c| sanitize_single_line(c))
1056 .collect::<Vec<_>>()
1057 .join(", ")
1058 ))
1059 })
1060 })
1061 .collect::<Result<_, _>>()?,
1062 };
1063 Ok(Profiler {
1064 names: indices.iter().map(|&i| columns[i].clone()).collect(),
1065 nulls: vec![0; indices.len()],
1066 indices,
1067 rows: 0,
1068 reservoir: Vec::new(),
1069 rng: 0x9E37_79B9_7F4A_7C15,
1070 bucket_rows: 1,
1071 buckets: Vec::new(),
1072 options,
1073 })
1074 }
1075
1076 pub fn push(&mut self, row: Row) {
1078 let mut cells: Row = self
1079 .indices
1080 .iter()
1081 .map(|&i| row.get(i).cloned().unwrap_or(Value::Null))
1082 .collect();
1083 if self
1086 .buckets
1087 .last()
1088 .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
1089 {
1090 if self.buckets.len() >= self.options.buckets.max(1) {
1091 self.merge_buckets();
1092 }
1093 if self
1094 .buckets
1095 .last()
1096 .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
1097 {
1098 self.buckets.push((0, vec![0; self.indices.len()]));
1099 }
1100 }
1101 let bucket = self.buckets.last_mut().expect("a bucket was just added");
1102 bucket.0 += 1;
1103 for (column, cell) in cells.iter_mut().enumerate() {
1106 if is_null(&self.options.nulls, cell) {
1107 self.nulls[column] += 1;
1108 bucket.1[column] += 1;
1109 *cell = Value::Null;
1110 }
1111 }
1112 let sample = self.options.sample.max(1);
1115 self.rows += 1;
1116 if self.reservoir.len() < sample {
1117 self.reservoir.push(cells);
1118 } else {
1119 let slot = self.next_random() % self.rows;
1120 if let Some(kept) = self.reservoir.get_mut(slot as usize) {
1121 *kept = cells;
1122 }
1123 }
1124 }
1125
1126 pub fn finish(self) -> Profile {
1128 let mut sample = Rows::new(self.names.clone());
1129 let size = self.reservoir.len();
1130 for row in self.reservoir {
1131 sample.push(row);
1132 }
1133 let inference = Inferrer::new()
1134 .null_tokens(self.options.nulls.iter().cloned())
1135 .infer(&sample);
1136 inference.apply(&mut sample);
1137 let stats = Stats::with_options(
1138 &sample,
1139 &StatsOptions {
1140 quantiles: vec![0.25, 0.75],
1141 top: self.options.top,
1142 },
1143 );
1144 let rows = self.rows;
1145 let columns = stats
1146 .columns()
1147 .iter()
1148 .zip(inference.columns())
1149 .enumerate()
1150 .map(|(index, (s, inferred))| {
1151 let mut column = describe(
1152 &sample,
1153 index,
1154 s,
1155 inferred.data_type().name(),
1156 self.options.bins,
1157 );
1158 column.nulls = self.nulls[index];
1159 column.null_rate = if rows == 0 {
1160 0.0
1161 } else {
1162 self.nulls[index] as f64 / rows as f64
1163 };
1164 column
1165 })
1166 .collect();
1167 let mut first = 1;
1168 let buckets = self
1169 .buckets
1170 .into_iter()
1171 .map(|(rows, nulls)| {
1172 let bucket = MissingBucket { first, rows, nulls };
1173 first += rows;
1174 bucket
1175 })
1176 .collect();
1177 Profile {
1178 name: None,
1179 rows,
1180 sample: size,
1181 columns,
1182 missing: MissingMap {
1183 bucket_rows: self.bucket_rows,
1184 buckets,
1185 },
1186 notes: Vec::new(),
1187 paths: None,
1188 malformed: 0,
1189 malformed_lines: Vec::new(),
1190 rules: Rules::default(),
1191 }
1192 }
1193
1194 fn merge_buckets(&mut self) {
1198 let merged = self
1199 .buckets
1200 .chunks(2)
1201 .map(|pair| {
1202 let mut rows = 0;
1203 let mut nulls = vec![0; self.indices.len()];
1204 for (r, n) in pair {
1205 rows += r;
1206 for (total, n) in nulls.iter_mut().zip(n) {
1207 *total += n;
1208 }
1209 }
1210 (rows, nulls)
1211 })
1212 .collect();
1213 self.buckets = merged;
1214 self.bucket_rows *= 2;
1215 }
1216
1217 fn next_random(&mut self) -> u64 {
1219 self.rng = self.rng.wrapping_add(0x9E37_79B9_7F4A_7C15);
1220 let mut z = self.rng;
1221 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1222 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1223 z ^ (z >> 31)
1224 }
1225}
1226
1227fn describe(
1231 sample: &Rows,
1232 index: usize,
1233 s: &ColumnStats,
1234 data_type: &str,
1235 bins: usize,
1236) -> ColumnProfile {
1237 let distribution = if s.count == 0 {
1238 Distribution::Empty
1239 } else if s.numeric {
1240 let values = sample.column(index).filter_map(|v| {
1241 v.as_f64()
1242 .or_else(|| parse_float(v.plain().trim()))
1243 .filter(|n| n.is_finite())
1244 });
1245 histogram(Histogram::new(values).bins(bins.max(1)))
1246 } else {
1247 let shown: usize = s.top.iter().map(|(_, n)| n).sum();
1248 Distribution::Top {
1249 values: s
1250 .top
1251 .iter()
1252 .map(|(value, count)| TopValue {
1253 value: value.clone(),
1254 count: *count,
1255 })
1256 .collect(),
1257 other: s.count - shown,
1258 }
1259 };
1260 let quantile = |q: f64| s.quantiles.iter().find(|(at, _)| *at == q).map(|v| v.1);
1261 ColumnProfile {
1262 name: s.name.clone(),
1263 data_type: data_type.to_string(),
1264 nulls: 0,
1265 null_rate: 0.0,
1266 count: s.count,
1267 distinct: s.distinct,
1268 min: s.min.clone(),
1269 max: s.max.clone(),
1270 mean: s.mean,
1271 median: s.median,
1272 p25: quantile(0.25),
1273 p75: quantile(0.75),
1274 distribution,
1275 present: None,
1276 absent: None,
1277 values: None,
1278 types: None,
1279 mixed: None,
1280 }
1281}
1282
1283fn is_null(tokens: &[String], value: &Value) -> bool {
1285 match value {
1286 Value::Null => true,
1287 Value::Int(_) | Value::Float(_) => false,
1288 Value::Str(text) => tokens.iter().any(|t| t == text.trim()),
1289 Value::Text(text) => {
1290 let plain = text.plain();
1291 tokens.iter().any(|t| t == plain.trim())
1292 }
1293 }
1294}
1295
1296fn histogram(histogram: Histogram) -> Distribution {
1298 let edges = histogram.edges();
1299 let counts = histogram.counts();
1300 let chart = histogram.to_bar_chart();
1301 let bins = chart
1302 .bars()
1303 .iter()
1304 .zip(counts)
1305 .enumerate()
1306 .map(|(i, (bar, count))| Bin {
1307 label: bar.label.clone(),
1308 low: edges[i],
1309 high: edges[i + 1],
1310 count,
1311 })
1312 .collect();
1313 Distribution::Histogram { bins }
1314}
1315
1316fn label(value: &str) -> String {
1318 let line = sanitize_single_line(value);
1319 if line.chars().count() <= LABEL_CHARS {
1320 return line;
1321 }
1322 let mut cut: String = line.chars().take(LABEL_CHARS - 1).collect();
1323 cut.push('…');
1324 cut
1325}
1326
1327fn nulls(count: u64, rate: f64) -> String {
1329 if count == 0 {
1330 "0".to_string()
1331 } else {
1332 format!("{} ({})", grouped(count), percent(rate))
1333 }
1334}
1335
1336fn percent(rate: f64) -> String {
1339 let p = rate * 100.0;
1340 if (99.5..100.0).contains(&p) {
1341 return ">99%".to_string();
1342 }
1343 if p > 0.0 && p < 0.1 {
1344 return "<0.1%".to_string();
1345 }
1346 if p < 1.0 {
1347 format!("{}%", number((p * 10.0).round() / 10.0))
1348 } else {
1349 format!("{}%", p.round())
1350 }
1351}
1352
1353fn grouped(n: u64) -> String {
1355 let digits = n.to_string();
1356 let mut out = String::new();
1357 for (i, c) in digits.chars().enumerate() {
1358 if i > 0 && (digits.len() - i).is_multiple_of(3) {
1359 out.push(',');
1360 }
1361 out.push(c);
1362 }
1363 out
1364}
1365
1366fn plural(n: u64, one: &'static str, many: &'static str) -> &'static str {
1367 if n == 1 { one } else { many }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use super::*;
1373
1374 fn profile(columns: &[&str], rows: &[&[&str]], options: ProfileOptions) -> Profile {
1375 let names: Vec<String> = columns.iter().map(|c| c.to_string()).collect();
1376 let mut profiler = Profiler::new(&names, options).unwrap();
1377 for row in rows {
1378 profiler.push(row.iter().map(|c| Value::from(*c)).collect());
1379 }
1380 profiler.finish()
1381 }
1382
1383 #[test]
1384 fn numbers_group_and_rates_round() {
1385 assert_eq!(grouped(0), "0");
1386 assert_eq!(grouped(999), "999");
1387 assert_eq!(grouped(1_000), "1,000");
1388 assert_eq!(grouped(1_234_567), "1,234,567");
1389 assert_eq!(percent(0.25), "25%");
1390 assert_eq!(percent(0.004), "0.4%");
1391 assert_eq!(percent(0.000_01), "<0.1%");
1392 assert_eq!(percent(0.999), ">99%");
1393 assert_eq!(percent(1.0), "100%");
1394 }
1395
1396 #[test]
1397 fn the_map_merges_buckets_in_order() {
1398 let options = ProfileOptions {
1399 buckets: 4,
1400 ..Default::default()
1401 };
1402 let rows: Vec<Vec<&str>> = (0..10)
1403 .map(|i| vec![if i % 3 == 0 { "" } else { "x" }])
1404 .collect();
1405 let rows: Vec<&[&str]> = rows.iter().map(Vec::as_slice).collect();
1406 let p = profile(&["a"], &rows, options);
1407 let map = p.missing();
1408 assert_eq!(map.bucket_rows, 4);
1409 let shape: Vec<(u64, u64, Vec<u64>)> = map
1410 .buckets
1411 .iter()
1412 .map(|b| (b.first, b.rows, b.nulls.clone()))
1413 .collect();
1414 assert_eq!(shape, [(1, 4, vec![2]), (5, 4, vec![1]), (9, 2, vec![1])]);
1416 assert_eq!(p.columns()[0].nulls, 4);
1417 }
1418
1419 #[test]
1420 fn a_large_input_is_sampled_with_exact_nulls() {
1421 let options = ProfileOptions {
1422 sample: 50,
1423 ..Default::default()
1424 };
1425 let names = vec!["n".to_string(), "s".to_string()];
1426 let mut profiler = Profiler::new(&names, options).unwrap();
1427 for i in 0..5_000i64 {
1428 let s = if i % 10 == 0 {
1429 Value::Null
1430 } else {
1431 Value::from("x")
1432 };
1433 profiler.push(vec![Value::Int(i), s]);
1434 }
1435 let p = profiler.finish();
1436 assert_eq!((p.rows(), p.sample_size(), p.sampled()), (5_000, 50, true));
1437 assert_eq!(p.columns()[1].nulls, 500);
1438 assert_eq!(p.columns()[1].null_rate, 0.1);
1439 let max: f64 = p.columns()[0].max.as_deref().unwrap().parse().unwrap();
1441 assert!(max > 2_500.0, "{max}");
1442 assert_eq!(p.to_json()["sampled"], true);
1443 assert_eq!(p.to_json()["sample"], 50);
1444 }
1445
1446 #[test]
1447 fn ragged_and_empty_input_never_panic() {
1448 let names = vec!["a".to_string(), "b".to_string()];
1449 let mut profiler = Profiler::new(&names, ProfileOptions::default()).unwrap();
1450 profiler.push(vec![]);
1451 profiler.push(vec![Value::from("1"), Value::from("2"), Value::from("3")]);
1452 let p = profiler.finish();
1453 assert_eq!(p.columns()[0].nulls, 1);
1454 let console = Console::builder().width(30).color_system(None).build();
1455 console.render_export(&p);
1456 let empty = Profiler::new(&[], ProfileOptions::default())
1457 .unwrap()
1458 .finish();
1459 assert_eq!(console.render_export(&empty), "0 rows, 0 columns\n");
1460 let none = Profiler::new(&names, ProfileOptions::default())
1461 .unwrap()
1462 .finish();
1463 assert!(console.render_export(&none).contains("no values"));
1464 }
1465
1466 #[test]
1467 fn unknown_columns_are_named() {
1468 let options = ProfileOptions {
1469 columns: Some(vec!["b".into(), "z".into()]),
1470 ..Default::default()
1471 };
1472 let error = Profiler::new(&["a".into(), "b".into()], options).unwrap_err();
1473 assert_eq!(error.to_string(), "no column \"z\"; the columns are a, b");
1474 }
1475
1476 #[test]
1477 fn the_model_round_trips_through_json() {
1478 let p = profile(
1479 &["k", "v"],
1480 &[&["a", "1"], &["b", "2.5"], &["a", ""]],
1481 ProfileOptions::default(),
1482 )
1483 .with_name("x.csv");
1484 let json = serde_json::to_string(&p).unwrap();
1485 let back: Profile = serde_json::from_str(&json).unwrap();
1486 assert_eq!(back, p);
1487 assert_eq!(p.columns()[1].data_type, "float");
1488 match &p.columns()[0].distribution {
1489 Distribution::Top { values, other } => {
1490 assert_eq!(values[0].value, "a");
1491 assert_eq!((values[0].count, *other), (2, 0));
1492 }
1493 other => panic!("{other:?}"),
1494 }
1495 }
1496}