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::{parse_float, Inferrer, DEFAULT_NULL_TOKENS};
76use crate::stats::{number, ColumnStats, Stats, StatsOptions};
77use crate::{DataError, Row, RowSource, Rows, Value};
78
79pub mod nested;
80
81pub use nested::{
82 path_key, JsonProfiler, PathSummary, TypeCounts, DEFAULT_DEPTH, DEFAULT_MAX_PATHS,
83 MAX_MALFORMED_LINES,
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 pub fn with_name(mut self, name: impl Into<String>) -> Self {
360 self.name = Some(name.into());
361 self
362 }
363
364 pub fn note(&mut self, note: impl Into<String>) {
366 self.notes.push(note.into());
367 }
368
369 pub fn rows(&self) -> u64 {
371 self.rows
372 }
373
374 pub fn sample_size(&self) -> usize {
376 self.sample
377 }
378
379 pub fn sampled(&self) -> bool {
383 match &self.paths {
384 Some(paths) => paths.sampled,
385 None => (self.sample as u64) < self.rows,
386 }
387 }
388
389 pub fn columns(&self) -> &[ColumnProfile] {
391 &self.columns
392 }
393
394 pub fn column(&self, name: &str) -> Option<&ColumnProfile> {
396 self.columns.iter().find(|c| c.name == name)
397 }
398
399 pub fn missing(&self) -> &MissingMap {
401 &self.missing
402 }
403
404 pub fn notes(&self) -> &[String] {
406 &self.notes
407 }
408
409 pub fn is_nested(&self) -> bool {
412 self.paths.is_some()
413 }
414
415 pub fn paths(&self) -> Option<&PathSummary> {
418 self.paths.as_ref()
419 }
420
421 pub fn malformed(&self) -> u64 {
423 self.malformed
424 }
425
426 pub fn malformed_lines(&self) -> &[usize] {
429 &self.malformed_lines
430 }
431
432 pub fn style_rules(mut self, rules: StyleRules) -> Self {
453 self.rules = Rules(rules);
454 self
455 }
456
457 pub fn rules(&self) -> &StyleRules {
459 &self.rules.0
460 }
461
462 pub fn table_headers(&self) -> &'static [&'static str] {
467 if self.is_nested() {
468 &["path", "types", "absent", "distinct", "min", "max", "mean"]
469 } else {
470 &["column", "type", "nulls", "distinct", "min", "max", "mean"]
471 }
472 }
473
474 fn rule_values(&self, c: &ColumnProfile) -> Vec<Value> {
477 let text = |v: &Option<String>| v.clone().map_or(Value::Null, Value::Str);
478 let count = |n: u64| Value::Int(i64::try_from(n).unwrap_or(i64::MAX));
479 let mut values = vec![Value::Str(c.name.clone())];
480 if self.is_nested() {
481 values.push(Value::Str(c.types.map(|t| t.mix()).unwrap_or_default()));
482 values.push(count(c.absent.unwrap_or(0)));
483 } else {
484 values.push(Value::Str(c.data_type.clone()));
485 values.push(count(c.nulls));
486 }
487 values.extend([
488 count(c.distinct as u64),
489 text(&c.min),
490 text(&c.max),
491 c.mean.map_or(Value::Null, Value::Float),
492 ]);
493 values
494 }
495
496 pub fn to_json(&self) -> serde_json::Value {
503 let mut value = serde_json::to_value(self).unwrap_or_default();
504 if let Some(object) = value.as_object_mut() {
505 object.insert("sampled".into(), self.sampled().into());
506 }
507 value
508 }
509
510 pub fn heading(&self) -> String {
513 let count = self.columns.len() as u64;
514 let body = if self.is_nested() {
515 let mut body = format!(
517 "{} {}, {} {}",
518 grouped(self.rows),
519 plural(self.rows, "record", "records"),
520 grouped(count),
521 plural(count, "path", "paths")
522 );
523 if self.sampled() {
524 body.push_str(&format!(
525 ", sampled to {} values a path",
526 grouped(self.sample as u64)
527 ));
528 }
529 body
530 } else {
531 let rows = if self.sampled() {
532 format!(
533 "sampled {} of {} rows",
534 grouped(self.sample as u64),
535 grouped(self.rows)
536 )
537 } else {
538 format!(
539 "{} {}",
540 grouped(self.rows),
541 plural(self.rows, "row", "rows")
542 )
543 };
544 format!("{rows}, {count} {}", plural(count, "column", "columns"))
545 };
546 match &self.name {
547 Some(name) => format!("{}: {body}", sanitize_single_line(name)),
548 None => body,
549 }
550 }
551
552 pub fn to_table(&self) -> Table {
561 self.table(None, Hold::NamesAndMixes)
562 }
563
564 fn table(&self, console: Option<&Console>, hold: Hold) -> Table {
567 let names_first = hold != Hold::Nothing;
568 let mut table = Table::new();
569 let headers = self.table_headers();
570 let widest = self
572 .columns
573 .iter()
574 .map(|c| c.name.chars().count())
575 .max()
576 .unwrap_or(0);
577 let nested_names = names_first && self.is_nested();
580 table.add_column_with(
581 Text::new(headers[0]),
582 ColumnOptions {
583 min_width: names_first.then(|| widest.clamp(6, if nested_names { 24 } else { 16 })),
584 no_wrap: nested_names,
585 ..ColumnOptions::default()
586 },
587 );
588 let mixes = hold == Hold::NamesAndMixes && self.is_nested();
590 table.add_column_with(
591 Text::new(headers[1]),
592 ColumnOptions {
593 no_wrap: mixes,
594 ..ColumnOptions::default()
595 },
596 );
597 for heading in &headers[2..] {
598 table.add_column_justify(*heading, Justify::Right);
599 }
600 let mixed = console.map(|console| style(console, "profile.mixed", STYLES));
601 let rules = &self.rules.0;
602 let values: Vec<Vec<Value>> = if rules.is_empty() {
603 Vec::new()
604 } else {
605 self.columns.iter().map(|c| self.rule_values(c)).collect()
606 };
607 let resolved = (!rules.is_empty()).then(|| rules.resolve(headers, &values));
608 for (index, c) in self.columns.iter().enumerate() {
609 let optional = |v: Option<f64>| v.map(number).unwrap_or_default();
610 let text = |v: &Option<String>| v.as_deref().map(label).unwrap_or_default();
611 let mut cells = vec![Text::new(sanitize_single_line(&c.name))];
612 if self.is_nested() {
613 let mix = c.types.unwrap_or_default().mix();
614 cells.push(match (c.mixed == Some(true), &mixed) {
615 (true, Some(style)) => Text::styled(format!("! {mix}"), style.clone()),
616 (true, None) => Text::new(format!("! {mix}")),
617 (false, _) => Text::new(mix),
618 });
619 cells.push(Text::new(nulls(
620 c.absent.unwrap_or(0),
621 c.absent_rate(self.rows).unwrap_or(0.0),
622 )));
623 } else {
624 cells.push(Text::new(c.data_type.clone()));
625 cells.push(Text::new(nulls(c.nulls, c.null_rate)));
627 }
628 let types = c.types.unwrap_or_default();
630 let container = c.count == 0 && types.object + types.array > 0;
631 cells.extend(
632 [
633 if container {
634 String::new()
635 } else {
636 c.distinct.to_string()
637 },
638 text(&c.min),
639 text(&c.max),
640 optional(c.mean),
641 ]
642 .into_iter()
643 .map(Text::new),
644 );
645 let (Some(resolved), Some(row)) = (&resolved, values.get(index)) else {
646 table.add_row_text(cells);
647 continue;
648 };
649 let cells = cells
650 .into_iter()
651 .enumerate()
652 .map(|(column, mut text)| {
653 if let Some(style) = resolved.cell_style(row, column) {
654 let len = text.plain().len();
655 text.stylize(style, 0, len);
656 }
657 Cell::Text(text)
658 })
659 .collect();
660 table.add_row_with(cells, resolved.row_style(row).map(Into::into), false);
661 }
662 if let Some(paths) = self.paths.as_ref().filter(|p| p.capped) {
664 let more = paths.dropped.len() as u64;
665 let count = if paths.more_dropped {
666 format!("{}+", grouped(more))
667 } else {
668 grouped(more)
669 };
670 let note = format!(
671 "{count} more {} not profiled (max_paths {})",
672 plural(more, "path", "paths"),
673 grouped(paths.max_paths as u64)
674 );
675 let dim = console.map(|console| style(console, "profile.note", STYLES));
676 let cell = |text: String| match &dim {
677 Some(style) => Text::styled(text, style.clone()),
678 None => Text::new(text),
679 };
680 table.add_row_text(vec![cell("…".into()), cell(note)]);
681 }
682 table
683 }
684
685 pub fn missing_heatmap(&self) -> Option<Heatmap> {
693 if !self.is_nested() && self.columns.iter().all(|c| c.nulls == 0) {
694 return None;
695 }
696 let missing = |i: usize| {
697 self.missing
698 .buckets
699 .iter()
700 .any(|b| b.nulls.get(i).is_some_and(|&n| n > 0))
701 };
702 let shown: Vec<usize> = (0..self.columns.len())
704 .filter(|&i| !self.is_nested() || missing(i))
705 .collect();
706 if shown.is_empty() {
707 return None;
708 }
709 let widest = shown
710 .iter()
711 .map(|&i| self.columns[i].name.chars().count())
712 .max()
713 .unwrap_or(1);
714 let mut map = Heatmap::new()
715 .columns(
716 shown
717 .iter()
718 .map(|&i| sanitize_single_line(&self.columns[i].name)),
719 )
720 .range(0.0, 100.0)
721 .cell_width((widest + 1).clamp(2, 12));
722 for bucket in &self.missing.buckets {
723 let last = bucket.first + bucket.rows.saturating_sub(1);
724 let label = if bucket.rows <= 1 {
725 bucket.first.to_string()
726 } else {
727 format!("{}-{last}", bucket.first)
728 };
729 let rows = bucket.rows.max(1) as f64;
730 let share =
731 |&i: &usize| bucket.nulls.get(i).copied().unwrap_or(0) as f64 * 100.0 / rows;
732 map = map.row(label, shown.iter().map(share));
733 }
734 Some(map)
735 }
736}
737
738#[derive(Clone, Copy, PartialEq, Eq)]
740enum Hold {
741 NamesAndMixes,
743 Names,
745 Nothing,
747}
748
749fn distribution_chart(distribution: &Distribution) -> Option<BarChart> {
752 let bars: Vec<(String, f64)> = match distribution {
753 Distribution::Histogram { bins } => bins
754 .iter()
755 .map(|b| (b.label.clone(), b.count as f64))
756 .collect(),
757 Distribution::Top { values, .. } => values
758 .iter()
759 .map(|v| (label(&v.value), v.count as f64))
760 .collect(),
761 Distribution::Empty => return None,
762 };
763 Some(BarChart::from_pairs(bars).bar_width(BAR_WIDTH))
764}
765
766impl Renderable for Profile {
767 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
768 let width = options.max_width;
769 if width == 0 {
770 return Vec::new();
771 }
772 let options = options.reset_height();
773 let note = style(console, "profile.note", STYLES);
774 let mut lines: Vec<Vec<Segment>> = Vec::new();
775 let text = |text: Text, indent: usize, lines: &mut Vec<Vec<Segment>>| {
776 block(console, &options, &text, indent, lines);
777 };
778
779 text(
780 Text::styled(self.heading(), style(console, "profile.title", STYLES)),
781 0,
782 &mut lines,
783 );
784 if self.sampled() {
785 let says = if self.is_nested() {
786 "types, distinct values, statistics and distributions are of a uniform sample \
787 of each path's values; presence, type mixes, nulls and the missing-value map \
788 count every record"
789 } else {
790 "types, distinct values, statistics and distributions are of a uniform sample; \
791 nulls and the missing-value map count every row"
792 };
793 text(Text::styled(says, note.clone()), 0, &mut lines);
794 }
795 for line in &self.notes {
796 text(
797 Text::styled(sanitize_single_line(line), note.clone()),
798 0,
799 &mut lines,
800 );
801 }
802 if self.columns.is_empty() {
803 return join(lines);
804 }
805 lines.push(Vec::new());
806 let fits = |table: &Table| {
811 let drawn = console.render(table, Some(&options));
812 let lines = Segment::split_lines(&drawn);
813 let within = lines.iter().all(|line| {
814 line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
815 });
816 let headings = || {
817 let heading: String = lines
818 .get(1)
819 .map(|line| line.iter().map(|s| s.text.to_string()).collect())
820 .unwrap_or_default();
821 self.table_headers().iter().all(|h| heading.contains(h))
822 };
823 within && (!self.is_nested() || headings())
824 };
825 let mut table = self.table(Some(console), Hold::NamesAndMixes);
826 let mut fit = fits(&table);
827 if self.is_nested() && !fit {
828 let names = self
831 .columns
832 .iter()
833 .map(|c| c.name.chars().count().min(24))
834 .max()
835 .unwrap_or(0);
836 if options.max_width >= names + 3 + 48 {
837 table = self.table(Some(console), Hold::Names);
838 let drawn = console.render(&table, Some(&options));
839 fit = Segment::split_lines(&drawn).iter().all(|line| {
840 line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
841 });
842 }
843 }
844 if !fit {
845 table = self.table(Some(console), Hold::Nothing);
846 }
847 block(console, &options, &table, 0, &mut lines);
848
849 for c in &self.columns {
850 lines.push(Vec::new());
851 let mut title = Text::styled(
852 sanitize_single_line(&c.name),
853 style(console, "profile.column", STYLES),
854 );
855 title.append(" · ", None);
856 title.append(
857 &c.data_type,
858 Some(style(console, "profile.type", STYLES).into()),
859 );
860 let types = c.types.unwrap_or_default();
863 if c.mixed == Some(true) {
864 title.append(" · ", None);
865 title.append(
866 &format!("mixed types: {}", types.mix()),
867 Some(style(console, "profile.mixed", STYLES).into()),
868 );
869 }
870 if let Some(absent) = c.absent.filter(|&n| n > 0) {
871 title.append(" · ", None);
872 title.append(
873 &format!(
874 "{} absent ({})",
875 grouped(absent),
876 percent(c.absent_rate(self.rows).unwrap_or(0.0))
877 ),
878 Some(style(console, "profile.null", STYLES).into()),
879 );
880 }
881 if c.nulls > 0 {
882 title.append(" · ", None);
883 title.append(
884 &format!(
885 "{} {} ({})",
886 grouped(c.nulls),
887 plural(c.nulls, "null", "nulls"),
888 percent(c.null_rate)
889 ),
890 Some(style(console, "profile.null", STYLES).into()),
891 );
892 }
893 let container = c.count == 0 && types.object + types.array > 0;
896 if matches!(c.distribution, Distribution::Histogram { .. }) {
897 let quartiles = [c.p25, c.median, c.p75].map(|q| q.map(number));
898 if let [Some(p25), Some(median), Some(p75)] = quartiles {
899 title.append(&format!(" · median {median} (p25 {p25}, p75 {p75})"), None);
900 }
901 } else if !container {
902 title.append(&format!(" · {} distinct", grouped(c.distinct as u64)), None);
903 }
904 text(title, 0, &mut lines);
905 match distribution_chart(&c.distribution) {
906 Some(chart) => block(console, &options, &chart, 2, &mut lines),
907 None if container => {}
908 None => text(Text::styled("no values", note.clone()), 2, &mut lines),
909 }
910 if let Distribution::Top { values, other } = &c.distribution {
911 if *other > 0 {
912 let more = c.distinct.saturating_sub(values.len()) as u64;
913 text(
914 Text::styled(
915 format!(
916 "+ {} other {} ({} {})",
917 grouped(more),
918 plural(more, "value", "values"),
919 grouped(*other as u64),
920 plural(*other as u64, "cell", "cells"),
921 ),
922 note.clone(),
923 ),
924 2,
925 &mut lines,
926 );
927 }
928 }
929 }
930
931 lines.push(Vec::new());
932 match self.missing_heatmap() {
933 None => text(
934 Text::styled("no missing values", note.clone()),
935 0,
936 &mut lines,
937 ),
938 Some(map) => {
939 let rows = self.missing.bucket_rows;
940 let (what, row, rows_name) = if self.is_nested() {
941 ("% of records absent or null", "a record", "records")
942 } else {
943 ("% null", "a row", "rows")
944 };
945 let per = if rows <= 1 {
946 format!("{row} each")
947 } else {
948 format!("{} {rows_name} each", grouped(rows))
949 };
950 text(
951 Text::styled(
952 format!("missing values ({what}; {rows_name} top to bottom, {per})"),
953 style(console, "profile.column", STYLES),
954 ),
955 0,
956 &mut lines,
957 );
958 block(console, &options, &map, 0, &mut lines);
959 }
960 }
961 join(lines)
962 }
963
964 fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
966 let widest = Segment::split_lines(&self.rich_render(console, options))
967 .iter()
968 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
969 .max()
970 .unwrap_or(0);
971 rich::measure::Measurement::new(widest, widest)
972 }
973}
974
975pub(crate) fn block(
977 console: &Console,
978 options: &ConsoleOptions,
979 renderable: &dyn Renderable,
980 indent: usize,
981 lines: &mut Vec<Vec<Segment>>,
982) {
983 let width = options.max_width.saturating_sub(indent).max(1);
984 let rendered = console.render(renderable, Some(&options.update_width(width)));
985 for line in Segment::split_lines(&rendered) {
986 let mut row = Vec::with_capacity(line.len() + 1);
987 if indent > 0 {
988 row.push(Segment::new(" ".repeat(indent), None));
989 }
990 row.extend(line);
991 lines.push(row);
992 }
993}
994
995pub(crate) fn join(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
997 let mut out = Vec::new();
998 for (i, line) in lines.into_iter().enumerate() {
999 if i > 0 {
1000 out.push(Segment::line());
1001 }
1002 out.extend(line);
1003 }
1004 out
1005}
1006
1007#[derive(Clone, Debug)]
1025pub struct Profiler {
1026 options: ProfileOptions,
1027 names: Vec<String>,
1028 indices: Vec<usize>,
1030 rows: u64,
1031 reservoir: Vec<Row>,
1032 rng: u64,
1033 nulls: Vec<u64>,
1034 bucket_rows: u64,
1035 buckets: Vec<(u64, Vec<u64>)>,
1036}
1037
1038impl Profiler {
1039 pub fn new(columns: &[String], options: ProfileOptions) -> Result<Self, DataError> {
1042 let indices: Vec<usize> = match &options.columns {
1043 None => (0..columns.len()).collect(),
1044 Some(wanted) => wanted
1045 .iter()
1046 .map(|name| {
1047 columns.iter().position(|c| c == name).ok_or_else(|| {
1048 DataError::new(format!(
1049 "no column {:?}; the columns are {}",
1050 sanitize_single_line(name),
1051 columns
1052 .iter()
1053 .map(|c| sanitize_single_line(c))
1054 .collect::<Vec<_>>()
1055 .join(", ")
1056 ))
1057 })
1058 })
1059 .collect::<Result<_, _>>()?,
1060 };
1061 Ok(Profiler {
1062 names: indices.iter().map(|&i| columns[i].clone()).collect(),
1063 nulls: vec![0; indices.len()],
1064 indices,
1065 rows: 0,
1066 reservoir: Vec::new(),
1067 rng: 0x9E37_79B9_7F4A_7C15,
1068 bucket_rows: 1,
1069 buckets: Vec::new(),
1070 options,
1071 })
1072 }
1073
1074 pub fn push(&mut self, row: Row) {
1076 let mut cells: Row = self
1077 .indices
1078 .iter()
1079 .map(|&i| row.get(i).cloned().unwrap_or(Value::Null))
1080 .collect();
1081 if self
1084 .buckets
1085 .last()
1086 .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
1087 {
1088 if self.buckets.len() >= self.options.buckets.max(1) {
1089 self.merge_buckets();
1090 }
1091 if self
1092 .buckets
1093 .last()
1094 .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
1095 {
1096 self.buckets.push((0, vec![0; self.indices.len()]));
1097 }
1098 }
1099 let bucket = self.buckets.last_mut().expect("a bucket was just added");
1100 bucket.0 += 1;
1101 for (column, cell) in cells.iter_mut().enumerate() {
1104 if is_null(&self.options.nulls, cell) {
1105 self.nulls[column] += 1;
1106 bucket.1[column] += 1;
1107 *cell = Value::Null;
1108 }
1109 }
1110 let sample = self.options.sample.max(1);
1113 self.rows += 1;
1114 if self.reservoir.len() < sample {
1115 self.reservoir.push(cells);
1116 } else {
1117 let slot = self.next_random() % self.rows;
1118 if let Some(kept) = self.reservoir.get_mut(slot as usize) {
1119 *kept = cells;
1120 }
1121 }
1122 }
1123
1124 pub fn finish(self) -> Profile {
1126 let mut sample = Rows::new(self.names.clone());
1127 let size = self.reservoir.len();
1128 for row in self.reservoir {
1129 sample.push(row);
1130 }
1131 let inference = Inferrer::new()
1132 .null_tokens(self.options.nulls.iter().cloned())
1133 .infer(&sample);
1134 inference.apply(&mut sample);
1135 let stats = Stats::with_options(
1136 &sample,
1137 &StatsOptions {
1138 quantiles: vec![0.25, 0.75],
1139 top: self.options.top,
1140 },
1141 );
1142 let rows = self.rows;
1143 let columns = stats
1144 .columns()
1145 .iter()
1146 .zip(inference.columns())
1147 .enumerate()
1148 .map(|(index, (s, inferred))| {
1149 let mut column = describe(
1150 &sample,
1151 index,
1152 s,
1153 inferred.data_type().name(),
1154 self.options.bins,
1155 );
1156 column.nulls = self.nulls[index];
1157 column.null_rate = if rows == 0 {
1158 0.0
1159 } else {
1160 self.nulls[index] as f64 / rows as f64
1161 };
1162 column
1163 })
1164 .collect();
1165 let mut first = 1;
1166 let buckets = self
1167 .buckets
1168 .into_iter()
1169 .map(|(rows, nulls)| {
1170 let bucket = MissingBucket { first, rows, nulls };
1171 first += rows;
1172 bucket
1173 })
1174 .collect();
1175 Profile {
1176 name: None,
1177 rows,
1178 sample: size,
1179 columns,
1180 missing: MissingMap {
1181 bucket_rows: self.bucket_rows,
1182 buckets,
1183 },
1184 notes: Vec::new(),
1185 paths: None,
1186 malformed: 0,
1187 malformed_lines: Vec::new(),
1188 rules: Rules::default(),
1189 }
1190 }
1191
1192 fn merge_buckets(&mut self) {
1196 let merged = self
1197 .buckets
1198 .chunks(2)
1199 .map(|pair| {
1200 let mut rows = 0;
1201 let mut nulls = vec![0; self.indices.len()];
1202 for (r, n) in pair {
1203 rows += r;
1204 for (total, n) in nulls.iter_mut().zip(n) {
1205 *total += n;
1206 }
1207 }
1208 (rows, nulls)
1209 })
1210 .collect();
1211 self.buckets = merged;
1212 self.bucket_rows *= 2;
1213 }
1214
1215 fn next_random(&mut self) -> u64 {
1217 self.rng = self.rng.wrapping_add(0x9E37_79B9_7F4A_7C15);
1218 let mut z = self.rng;
1219 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1220 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1221 z ^ (z >> 31)
1222 }
1223}
1224
1225fn describe(
1229 sample: &Rows,
1230 index: usize,
1231 s: &ColumnStats,
1232 data_type: &str,
1233 bins: usize,
1234) -> ColumnProfile {
1235 let distribution = if s.count == 0 {
1236 Distribution::Empty
1237 } else if s.numeric {
1238 let values = sample.column(index).filter_map(|v| {
1239 v.as_f64()
1240 .or_else(|| parse_float(v.plain().trim()))
1241 .filter(|n| n.is_finite())
1242 });
1243 histogram(Histogram::new(values).bins(bins.max(1)))
1244 } else {
1245 let shown: usize = s.top.iter().map(|(_, n)| n).sum();
1246 Distribution::Top {
1247 values: s
1248 .top
1249 .iter()
1250 .map(|(value, count)| TopValue {
1251 value: value.clone(),
1252 count: *count,
1253 })
1254 .collect(),
1255 other: s.count - shown,
1256 }
1257 };
1258 let quantile = |q: f64| s.quantiles.iter().find(|(at, _)| *at == q).map(|v| v.1);
1259 ColumnProfile {
1260 name: s.name.clone(),
1261 data_type: data_type.to_string(),
1262 nulls: 0,
1263 null_rate: 0.0,
1264 count: s.count,
1265 distinct: s.distinct,
1266 min: s.min.clone(),
1267 max: s.max.clone(),
1268 mean: s.mean,
1269 median: s.median,
1270 p25: quantile(0.25),
1271 p75: quantile(0.75),
1272 distribution,
1273 present: None,
1274 absent: None,
1275 values: None,
1276 types: None,
1277 mixed: None,
1278 }
1279}
1280
1281fn is_null(tokens: &[String], value: &Value) -> bool {
1283 match value {
1284 Value::Null => true,
1285 Value::Int(_) | Value::Float(_) => false,
1286 Value::Str(text) => tokens.iter().any(|t| t == text.trim()),
1287 Value::Text(text) => {
1288 let plain = text.plain();
1289 tokens.iter().any(|t| t == plain.trim())
1290 }
1291 }
1292}
1293
1294fn histogram(histogram: Histogram) -> Distribution {
1296 let edges = histogram.edges();
1297 let counts = histogram.counts();
1298 let chart = histogram.to_bar_chart();
1299 let bins = chart
1300 .bars()
1301 .iter()
1302 .zip(counts)
1303 .enumerate()
1304 .map(|(i, (bar, count))| Bin {
1305 label: bar.label.clone(),
1306 low: edges[i],
1307 high: edges[i + 1],
1308 count,
1309 })
1310 .collect();
1311 Distribution::Histogram { bins }
1312}
1313
1314fn label(value: &str) -> String {
1316 let line = sanitize_single_line(value);
1317 if line.chars().count() <= LABEL_CHARS {
1318 return line;
1319 }
1320 let mut cut: String = line.chars().take(LABEL_CHARS - 1).collect();
1321 cut.push('…');
1322 cut
1323}
1324
1325fn nulls(count: u64, rate: f64) -> String {
1327 if count == 0 {
1328 "0".to_string()
1329 } else {
1330 format!("{} ({})", grouped(count), percent(rate))
1331 }
1332}
1333
1334fn percent(rate: f64) -> String {
1337 let p = rate * 100.0;
1338 if (99.5..100.0).contains(&p) {
1339 return ">99%".to_string();
1340 }
1341 if p > 0.0 && p < 0.1 {
1342 return "<0.1%".to_string();
1343 }
1344 if p < 1.0 {
1345 format!("{}%", number((p * 10.0).round() / 10.0))
1346 } else {
1347 format!("{}%", p.round())
1348 }
1349}
1350
1351fn grouped(n: u64) -> String {
1353 let digits = n.to_string();
1354 let mut out = String::new();
1355 for (i, c) in digits.chars().enumerate() {
1356 if i > 0 && (digits.len() - i).is_multiple_of(3) {
1357 out.push(',');
1358 }
1359 out.push(c);
1360 }
1361 out
1362}
1363
1364fn plural(n: u64, one: &'static str, many: &'static str) -> &'static str {
1365 if n == 1 {
1366 one
1367 } else {
1368 many
1369 }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375
1376 fn profile(columns: &[&str], rows: &[&[&str]], options: ProfileOptions) -> Profile {
1377 let names: Vec<String> = columns.iter().map(|c| c.to_string()).collect();
1378 let mut profiler = Profiler::new(&names, options).unwrap();
1379 for row in rows {
1380 profiler.push(row.iter().map(|c| Value::from(*c)).collect());
1381 }
1382 profiler.finish()
1383 }
1384
1385 #[test]
1386 fn numbers_group_and_rates_round() {
1387 assert_eq!(grouped(0), "0");
1388 assert_eq!(grouped(999), "999");
1389 assert_eq!(grouped(1_000), "1,000");
1390 assert_eq!(grouped(1_234_567), "1,234,567");
1391 assert_eq!(percent(0.25), "25%");
1392 assert_eq!(percent(0.004), "0.4%");
1393 assert_eq!(percent(0.000_01), "<0.1%");
1394 assert_eq!(percent(0.999), ">99%");
1395 assert_eq!(percent(1.0), "100%");
1396 }
1397
1398 #[test]
1399 fn the_map_merges_buckets_in_order() {
1400 let options = ProfileOptions {
1401 buckets: 4,
1402 ..Default::default()
1403 };
1404 let rows: Vec<Vec<&str>> = (0..10)
1405 .map(|i| vec![if i % 3 == 0 { "" } else { "x" }])
1406 .collect();
1407 let rows: Vec<&[&str]> = rows.iter().map(Vec::as_slice).collect();
1408 let p = profile(&["a"], &rows, options);
1409 let map = p.missing();
1410 assert_eq!(map.bucket_rows, 4);
1411 let shape: Vec<(u64, u64, Vec<u64>)> = map
1412 .buckets
1413 .iter()
1414 .map(|b| (b.first, b.rows, b.nulls.clone()))
1415 .collect();
1416 assert_eq!(shape, [(1, 4, vec![2]), (5, 4, vec![1]), (9, 2, vec![1])]);
1418 assert_eq!(p.columns()[0].nulls, 4);
1419 }
1420
1421 #[test]
1422 fn a_large_input_is_sampled_with_exact_nulls() {
1423 let options = ProfileOptions {
1424 sample: 50,
1425 ..Default::default()
1426 };
1427 let names = vec!["n".to_string(), "s".to_string()];
1428 let mut profiler = Profiler::new(&names, options).unwrap();
1429 for i in 0..5_000i64 {
1430 let s = if i % 10 == 0 {
1431 Value::Null
1432 } else {
1433 Value::from("x")
1434 };
1435 profiler.push(vec![Value::Int(i), s]);
1436 }
1437 let p = profiler.finish();
1438 assert_eq!((p.rows(), p.sample_size(), p.sampled()), (5_000, 50, true));
1439 assert_eq!(p.columns()[1].nulls, 500);
1440 assert_eq!(p.columns()[1].null_rate, 0.1);
1441 let max: f64 = p.columns()[0].max.as_deref().unwrap().parse().unwrap();
1443 assert!(max > 2_500.0, "{max}");
1444 assert_eq!(p.to_json()["sampled"], true);
1445 assert_eq!(p.to_json()["sample"], 50);
1446 }
1447
1448 #[test]
1449 fn ragged_and_empty_input_never_panic() {
1450 let names = vec!["a".to_string(), "b".to_string()];
1451 let mut profiler = Profiler::new(&names, ProfileOptions::default()).unwrap();
1452 profiler.push(vec![]);
1453 profiler.push(vec![Value::from("1"), Value::from("2"), Value::from("3")]);
1454 let p = profiler.finish();
1455 assert_eq!(p.columns()[0].nulls, 1);
1456 let console = Console::builder().width(30).color_system(None).build();
1457 console.render_export(&p);
1458 let empty = Profiler::new(&[], ProfileOptions::default())
1459 .unwrap()
1460 .finish();
1461 assert_eq!(console.render_export(&empty), "0 rows, 0 columns\n");
1462 let none = Profiler::new(&names, ProfileOptions::default())
1463 .unwrap()
1464 .finish();
1465 assert!(console.render_export(&none).contains("no values"));
1466 }
1467
1468 #[test]
1469 fn unknown_columns_are_named() {
1470 let options = ProfileOptions {
1471 columns: Some(vec!["b".into(), "z".into()]),
1472 ..Default::default()
1473 };
1474 let error = Profiler::new(&["a".into(), "b".into()], options).unwrap_err();
1475 assert_eq!(error.to_string(), "no column \"z\"; the columns are a, b");
1476 }
1477
1478 #[test]
1479 fn the_model_round_trips_through_json() {
1480 let p = profile(
1481 &["k", "v"],
1482 &[&["a", "1"], &["b", "2.5"], &["a", ""]],
1483 ProfileOptions::default(),
1484 )
1485 .with_name("x.csv");
1486 let json = serde_json::to_string(&p).unwrap();
1487 let back: Profile = serde_json::from_str(&json).unwrap();
1488 assert_eq!(back, p);
1489 assert_eq!(p.columns()[1].data_type, "float");
1490 match &p.columns()[0].distribution {
1491 Distribution::Top { values, other } => {
1492 assert_eq!(values[0].value, "a");
1493 assert_eq!((values[0].count, *other), (2, 0));
1494 }
1495 other => panic!("{other:?}"),
1496 }
1497 }
1498}