use rich::{
Cell, ColumnOptions, Console, ConsoleOptions, Justify, Renderable, Segment, Style, Table, Text,
};
use rich_ext::chart::{BarChart, Heatmap, Histogram};
use rich_ext::sanitize::sanitize_single_line;
use rich_ext::table::StyleRules;
use serde::{Deserialize, Serialize};
use crate::infer::{DEFAULT_NULL_TOKENS, Inferrer, parse_float};
use crate::stats::{ColumnStats, Stats, StatsOptions, number};
use crate::{DataError, Row, RowSource, Rows, Value};
pub mod nested;
pub use nested::{
DEFAULT_DEPTH, DEFAULT_MAX_PATHS, JsonProfiler, MAX_MALFORMED_LINES, PathSummary, TypeCounts,
path_key,
};
pub const DEFAULT_SAMPLE: usize = 10_000;
pub const DEFAULT_TOP: usize = 5;
pub const DEFAULT_BINS: usize = 10;
pub const DEFAULT_BUCKETS: usize = 20;
const LABEL_CHARS: usize = 24;
const BAR_WIDTH: usize = 24;
pub const STYLES: &[(&str, &str)] = &[
("profile.title", "bold"),
("profile.column", "bold"),
("profile.type", "cyan"),
("profile.note", "dim"),
("profile.null", "yellow"),
("profile.mixed", "magenta"),
];
pub(crate) fn style(console: &Console, key: &str, styles: &[(&str, &str)]) -> Style {
if let Some(style) = console.theme().get(key) {
return style.clone();
}
styles
.iter()
.find(|(name, _)| *name == key)
.and_then(|(_, spec)| Style::parse(spec).ok())
.unwrap_or_default()
}
#[derive(Clone, Debug, PartialEq)]
pub struct ProfileOptions {
pub sample: usize,
pub top: usize,
pub bins: usize,
pub buckets: usize,
pub columns: Option<Vec<String>>,
pub nulls: Vec<String>,
pub depth: usize,
pub max_paths: usize,
}
impl Default for ProfileOptions {
fn default() -> Self {
ProfileOptions {
sample: DEFAULT_SAMPLE,
top: DEFAULT_TOP,
bins: DEFAULT_BINS,
buckets: DEFAULT_BUCKETS,
columns: None,
nulls: DEFAULT_NULL_TOKENS.map(String::from).to_vec(),
depth: DEFAULT_DEPTH,
max_paths: DEFAULT_MAX_PATHS,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ColumnProfile {
pub name: String,
#[serde(rename = "type")]
pub data_type: String,
pub nulls: u64,
pub null_rate: f64,
pub count: usize,
pub distinct: usize,
pub min: Option<String>,
pub max: Option<String>,
pub mean: Option<f64>,
pub median: Option<f64>,
pub p25: Option<f64>,
pub p75: Option<f64>,
pub distribution: Distribution,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub present: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub absent: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub values: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub types: Option<TypeCounts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mixed: Option<bool>,
}
impl ColumnProfile {
pub fn absent_rate(&self, rows: u64) -> Option<f64> {
self.absent.map(|absent| {
if rows == 0 {
0.0
} else {
absent as f64 / rows as f64
}
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Distribution {
Histogram {
bins: Vec<Bin>,
},
Top {
values: Vec<TopValue>,
other: usize,
},
Empty,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Bin {
pub label: String,
pub low: f64,
pub high: f64,
pub count: usize,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TopValue {
pub value: String,
pub count: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MissingMap {
pub bucket_rows: u64,
pub buckets: Vec<MissingBucket>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MissingBucket {
pub first: u64,
pub rows: u64,
pub nulls: Vec<u64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Profile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
rows: u64,
sample: usize,
columns: Vec<ColumnProfile>,
missing: MissingMap,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
notes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
paths: Option<PathSummary>,
#[serde(default, skip_serializing_if = "is_zero")]
malformed: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
malformed_lines: Vec<usize>,
#[serde(skip)]
rules: Rules,
}
fn is_zero(n: &u64) -> bool {
*n == 0
}
#[derive(Clone, Debug, Default)]
struct Rules(StyleRules);
impl PartialEq for Rules {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Profile {
pub fn of(rows: &Rows) -> Self {
let mut profiler = Profiler::new(rows.columns(), ProfileOptions::default())
.expect("every column of the rows exists");
for row in rows.rows() {
profiler.push(row.clone());
}
profiler.finish()
}
pub fn from_source(
mut source: impl RowSource,
options: ProfileOptions,
) -> Result<Self, DataError> {
let mut profiler = Profiler::new(source.columns(), options)?;
while let Some(row) = source.next_row() {
profiler.push(row?);
}
Ok(profiler.finish())
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn note(&mut self, note: impl Into<String>) {
self.notes.push(note.into());
}
pub fn rows(&self) -> u64 {
self.rows
}
pub fn sample_size(&self) -> usize {
self.sample
}
pub fn sampled(&self) -> bool {
match &self.paths {
Some(paths) => paths.sampled,
None => (self.sample as u64) < self.rows,
}
}
pub fn columns(&self) -> &[ColumnProfile] {
&self.columns
}
pub fn column(&self, name: &str) -> Option<&ColumnProfile> {
self.columns.iter().find(|c| c.name == name)
}
pub fn missing(&self) -> &MissingMap {
&self.missing
}
pub fn notes(&self) -> &[String] {
&self.notes
}
pub fn is_nested(&self) -> bool {
self.paths.is_some()
}
pub fn paths(&self) -> Option<&PathSummary> {
self.paths.as_ref()
}
pub fn malformed(&self) -> u64 {
self.malformed
}
pub fn malformed_lines(&self) -> &[usize] {
&self.malformed_lines
}
#[must_use]
pub fn style_rules(mut self, rules: StyleRules) -> Self {
self.rules = Rules(rules);
self
}
pub fn rules(&self) -> &StyleRules {
&self.rules.0
}
pub fn table_headers(&self) -> &'static [&'static str] {
if self.is_nested() {
&["path", "types", "absent", "distinct", "min", "max", "mean"]
} else {
&["column", "type", "nulls", "distinct", "min", "max", "mean"]
}
}
fn rule_values(&self, c: &ColumnProfile) -> Vec<Value> {
let text = |v: &Option<String>| v.clone().map_or(Value::Null, Value::Str);
let count = |n: u64| Value::Int(i64::try_from(n).unwrap_or(i64::MAX));
let mut values = vec![Value::Str(c.name.clone())];
if self.is_nested() {
values.push(Value::Str(c.types.map(|t| t.mix()).unwrap_or_default()));
values.push(count(c.absent.unwrap_or(0)));
} else {
values.push(Value::Str(c.data_type.clone()));
values.push(count(c.nulls));
}
values.extend([
count(c.distinct as u64),
text(&c.min),
text(&c.max),
c.mean.map_or(Value::Null, Value::Float),
]);
values
}
pub fn to_json(&self) -> serde_json::Value {
let mut value = serde_json::to_value(self).unwrap_or_default();
if let Some(object) = value.as_object_mut() {
object.insert("sampled".into(), self.sampled().into());
}
value
}
pub fn heading(&self) -> String {
let count = self.columns.len() as u64;
let body = if self.is_nested() {
let mut body = format!(
"{} {}, {} {}",
grouped(self.rows),
plural(self.rows, "record", "records"),
grouped(count),
plural(count, "path", "paths")
);
if self.sampled() {
body.push_str(&format!(
", sampled to {} values a path",
grouped(self.sample as u64)
));
}
body
} else {
let rows = if self.sampled() {
format!(
"sampled {} of {} rows",
grouped(self.sample as u64),
grouped(self.rows)
)
} else {
format!(
"{} {}",
grouped(self.rows),
plural(self.rows, "row", "rows")
)
};
format!("{rows}, {count} {}", plural(count, "column", "columns"))
};
match &self.name {
Some(name) => format!("{}: {body}", sanitize_single_line(name)),
None => body,
}
}
pub fn to_table(&self) -> Table {
self.table(None, Hold::NamesAndMixes)
}
fn table(&self, console: Option<&Console>, hold: Hold) -> Table {
let names_first = hold != Hold::Nothing;
let mut table = Table::new();
let headers = self.table_headers();
let widest = self
.columns
.iter()
.map(|c| c.name.chars().count())
.max()
.unwrap_or(0);
let nested_names = names_first && self.is_nested();
table.add_column_with(
Text::new(headers[0]),
ColumnOptions {
min_width: names_first.then(|| widest.clamp(6, if nested_names { 24 } else { 16 })),
no_wrap: nested_names,
..ColumnOptions::default()
},
);
let mixes = hold == Hold::NamesAndMixes && self.is_nested();
table.add_column_with(
Text::new(headers[1]),
ColumnOptions {
no_wrap: mixes,
..ColumnOptions::default()
},
);
for heading in &headers[2..] {
table.add_column_justify(*heading, Justify::Right);
}
let mixed = console.map(|console| style(console, "profile.mixed", STYLES));
let rules = &self.rules.0;
let values: Vec<Vec<Value>> = if rules.is_empty() {
Vec::new()
} else {
self.columns.iter().map(|c| self.rule_values(c)).collect()
};
let resolved = (!rules.is_empty()).then(|| rules.resolve(headers, &values));
for (index, c) in self.columns.iter().enumerate() {
let optional = |v: Option<f64>| v.map(number).unwrap_or_default();
let text = |v: &Option<String>| v.as_deref().map(label).unwrap_or_default();
let mut cells = vec![Text::new(sanitize_single_line(&c.name))];
if self.is_nested() {
let mix = c.types.unwrap_or_default().mix();
cells.push(match (c.mixed == Some(true), &mixed) {
(true, Some(style)) => Text::styled(format!("! {mix}"), style.clone()),
(true, None) => Text::new(format!("! {mix}")),
(false, _) => Text::new(mix),
});
cells.push(Text::new(nulls(
c.absent.unwrap_or(0),
c.absent_rate(self.rows).unwrap_or(0.0),
)));
} else {
cells.push(Text::new(c.data_type.clone()));
cells.push(Text::new(nulls(c.nulls, c.null_rate)));
}
let types = c.types.unwrap_or_default();
let container = c.count == 0 && types.object + types.array > 0;
cells.extend(
[
if container {
String::new()
} else {
c.distinct.to_string()
},
text(&c.min),
text(&c.max),
optional(c.mean),
]
.into_iter()
.map(Text::new),
);
let (Some(resolved), Some(row)) = (&resolved, values.get(index)) else {
table.add_row_text(cells);
continue;
};
let cells = cells
.into_iter()
.enumerate()
.map(|(column, mut text)| {
if let Some(style) = resolved.cell_style(row, column) {
let len = text.plain().len();
text.stylize(style, 0, len);
}
Cell::Text(text)
})
.collect();
table.add_row_with(cells, resolved.row_style(row).map(Into::into), false);
}
if let Some(paths) = self.paths.as_ref().filter(|p| p.capped) {
let more = paths.dropped.len() as u64;
let count = if paths.more_dropped {
format!("{}+", grouped(more))
} else {
grouped(more)
};
let note = format!(
"{count} more {} not profiled (max_paths {})",
plural(more, "path", "paths"),
grouped(paths.max_paths as u64)
);
let dim = console.map(|console| style(console, "profile.note", STYLES));
let cell = |text: String| match &dim {
Some(style) => Text::styled(text, style.clone()),
None => Text::new(text),
};
table.add_row_text(vec![cell("…".into()), cell(note)]);
}
table
}
pub fn missing_heatmap(&self) -> Option<Heatmap> {
if !self.is_nested() && self.columns.iter().all(|c| c.nulls == 0) {
return None;
}
let missing = |i: usize| {
self.missing
.buckets
.iter()
.any(|b| b.nulls.get(i).is_some_and(|&n| n > 0))
};
let shown: Vec<usize> = (0..self.columns.len())
.filter(|&i| !self.is_nested() || missing(i))
.collect();
if shown.is_empty() {
return None;
}
let widest = shown
.iter()
.map(|&i| self.columns[i].name.chars().count())
.max()
.unwrap_or(1);
let mut map = Heatmap::new()
.columns(
shown
.iter()
.map(|&i| sanitize_single_line(&self.columns[i].name)),
)
.range(0.0, 100.0)
.cell_width((widest + 1).clamp(2, 12));
for bucket in &self.missing.buckets {
let last = bucket.first + bucket.rows.saturating_sub(1);
let label = if bucket.rows <= 1 {
bucket.first.to_string()
} else {
format!("{}-{last}", bucket.first)
};
let rows = bucket.rows.max(1) as f64;
let share =
|&i: &usize| bucket.nulls.get(i).copied().unwrap_or(0) as f64 * 100.0 / rows;
map = map.row(label, shown.iter().map(share));
}
Some(map)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Hold {
NamesAndMixes,
Names,
Nothing,
}
fn distribution_chart(distribution: &Distribution) -> Option<BarChart> {
let bars: Vec<(String, f64)> = match distribution {
Distribution::Histogram { bins } => bins
.iter()
.map(|b| (b.label.clone(), b.count as f64))
.collect(),
Distribution::Top { values, .. } => values
.iter()
.map(|v| (label(&v.value), v.count as f64))
.collect(),
Distribution::Empty => return None,
};
Some(BarChart::from_pairs(bars).bar_width(BAR_WIDTH))
}
impl Renderable for Profile {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let width = options.max_width;
if width == 0 {
return Vec::new();
}
let options = options.reset_height();
let note = style(console, "profile.note", STYLES);
let mut lines: Vec<Vec<Segment>> = Vec::new();
let text = |text: Text, indent: usize, lines: &mut Vec<Vec<Segment>>| {
block(console, &options, &text, indent, lines);
};
text(
Text::styled(self.heading(), style(console, "profile.title", STYLES)),
0,
&mut lines,
);
if self.sampled() {
let says = if self.is_nested() {
"types, distinct values, statistics and distributions are of a uniform sample \
of each path's values; presence, type mixes, nulls and the missing-value map \
count every record"
} else {
"types, distinct values, statistics and distributions are of a uniform sample; \
nulls and the missing-value map count every row"
};
text(Text::styled(says, note.clone()), 0, &mut lines);
}
for line in &self.notes {
text(
Text::styled(sanitize_single_line(line), note.clone()),
0,
&mut lines,
);
}
if self.columns.is_empty() {
return join(lines);
}
lines.push(Vec::new());
let fits = |table: &Table| {
let drawn = console.render(table, Some(&options));
let lines = Segment::split_lines(&drawn);
let within = lines.iter().all(|line| {
line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
});
let headings = || {
let heading: String = lines
.get(1)
.map(|line| line.iter().map(|s| s.text.to_string()).collect())
.unwrap_or_default();
self.table_headers().iter().all(|h| heading.contains(h))
};
within && (!self.is_nested() || headings())
};
let mut table = self.table(Some(console), Hold::NamesAndMixes);
let mut fit = fits(&table);
if self.is_nested() && !fit {
let names = self
.columns
.iter()
.map(|c| c.name.chars().count().min(24))
.max()
.unwrap_or(0);
if options.max_width >= names + 3 + 48 {
table = self.table(Some(console), Hold::Names);
let drawn = console.render(&table, Some(&options));
fit = Segment::split_lines(&drawn).iter().all(|line| {
line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
});
}
}
if !fit {
table = self.table(Some(console), Hold::Nothing);
}
block(console, &options, &table, 0, &mut lines);
for c in &self.columns {
lines.push(Vec::new());
let mut title = Text::styled(
sanitize_single_line(&c.name),
style(console, "profile.column", STYLES),
);
title.append(" · ", None);
title.append(
&c.data_type,
Some(style(console, "profile.type", STYLES).into()),
);
let types = c.types.unwrap_or_default();
if c.mixed == Some(true) {
title.append(" · ", None);
title.append(
&format!("mixed types: {}", types.mix()),
Some(style(console, "profile.mixed", STYLES).into()),
);
}
if let Some(absent) = c.absent.filter(|&n| n > 0) {
title.append(" · ", None);
title.append(
&format!(
"{} absent ({})",
grouped(absent),
percent(c.absent_rate(self.rows).unwrap_or(0.0))
),
Some(style(console, "profile.null", STYLES).into()),
);
}
if c.nulls > 0 {
title.append(" · ", None);
title.append(
&format!(
"{} {} ({})",
grouped(c.nulls),
plural(c.nulls, "null", "nulls"),
percent(c.null_rate)
),
Some(style(console, "profile.null", STYLES).into()),
);
}
let container = c.count == 0 && types.object + types.array > 0;
if matches!(c.distribution, Distribution::Histogram { .. }) {
let quartiles = [c.p25, c.median, c.p75].map(|q| q.map(number));
if let [Some(p25), Some(median), Some(p75)] = quartiles {
title.append(&format!(" · median {median} (p25 {p25}, p75 {p75})"), None);
}
} else if !container {
title.append(&format!(" · {} distinct", grouped(c.distinct as u64)), None);
}
text(title, 0, &mut lines);
match distribution_chart(&c.distribution) {
Some(chart) => block(console, &options, &chart, 2, &mut lines),
None if container => {}
None => text(Text::styled("no values", note.clone()), 2, &mut lines),
}
if let Distribution::Top { values, other } = &c.distribution
&& *other > 0
{
let more = c.distinct.saturating_sub(values.len()) as u64;
text(
Text::styled(
format!(
"+ {} other {} ({} {})",
grouped(more),
plural(more, "value", "values"),
grouped(*other as u64),
plural(*other as u64, "cell", "cells"),
),
note.clone(),
),
2,
&mut lines,
);
}
}
lines.push(Vec::new());
match self.missing_heatmap() {
None => text(
Text::styled("no missing values", note.clone()),
0,
&mut lines,
),
Some(map) => {
let rows = self.missing.bucket_rows;
let (what, row, rows_name) = if self.is_nested() {
("% of records absent or null", "a record", "records")
} else {
("% null", "a row", "rows")
};
let per = if rows <= 1 {
format!("{row} each")
} else {
format!("{} {rows_name} each", grouped(rows))
};
text(
Text::styled(
format!("missing values ({what}; {rows_name} top to bottom, {per})"),
style(console, "profile.column", STYLES),
),
0,
&mut lines,
);
block(console, &options, &map, 0, &mut lines);
}
}
join(lines)
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
let widest = Segment::split_lines(&self.rich_render(console, options))
.iter()
.map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
.max()
.unwrap_or(0);
rich::measure::Measurement::new(widest, widest)
}
}
pub(crate) fn block(
console: &Console,
options: &ConsoleOptions,
renderable: &dyn Renderable,
indent: usize,
lines: &mut Vec<Vec<Segment>>,
) {
let width = options.max_width.saturating_sub(indent).max(1);
let rendered = console.render(renderable, Some(&options.update_width(width)));
for line in Segment::split_lines(&rendered) {
let mut row = Vec::with_capacity(line.len() + 1);
if indent > 0 {
row.push(Segment::new(" ".repeat(indent), None));
}
row.extend(line);
lines.push(row);
}
}
pub(crate) fn join(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
let mut out = Vec::new();
for (i, line) in lines.into_iter().enumerate() {
if i > 0 {
out.push(Segment::line());
}
out.extend(line);
}
out
}
#[derive(Clone, Debug)]
pub struct Profiler {
options: ProfileOptions,
names: Vec<String>,
indices: Vec<usize>,
rows: u64,
reservoir: Vec<Row>,
rng: u64,
nulls: Vec<u64>,
bucket_rows: u64,
buckets: Vec<(u64, Vec<u64>)>,
}
impl Profiler {
pub fn new(columns: &[String], options: ProfileOptions) -> Result<Self, DataError> {
let indices: Vec<usize> = match &options.columns {
None => (0..columns.len()).collect(),
Some(wanted) => wanted
.iter()
.map(|name| {
columns.iter().position(|c| c == name).ok_or_else(|| {
DataError::new(format!(
"no column {:?}; the columns are {}",
sanitize_single_line(name),
columns
.iter()
.map(|c| sanitize_single_line(c))
.collect::<Vec<_>>()
.join(", ")
))
})
})
.collect::<Result<_, _>>()?,
};
Ok(Profiler {
names: indices.iter().map(|&i| columns[i].clone()).collect(),
nulls: vec![0; indices.len()],
indices,
rows: 0,
reservoir: Vec::new(),
rng: 0x9E37_79B9_7F4A_7C15,
bucket_rows: 1,
buckets: Vec::new(),
options,
})
}
pub fn push(&mut self, row: Row) {
let mut cells: Row = self
.indices
.iter()
.map(|&i| row.get(i).cloned().unwrap_or(Value::Null))
.collect();
if self
.buckets
.last()
.is_none_or(|(rows, _)| *rows >= self.bucket_rows)
{
if self.buckets.len() >= self.options.buckets.max(1) {
self.merge_buckets();
}
if self
.buckets
.last()
.is_none_or(|(rows, _)| *rows >= self.bucket_rows)
{
self.buckets.push((0, vec![0; self.indices.len()]));
}
}
let bucket = self.buckets.last_mut().expect("a bucket was just added");
bucket.0 += 1;
for (column, cell) in cells.iter_mut().enumerate() {
if is_null(&self.options.nulls, cell) {
self.nulls[column] += 1;
bucket.1[column] += 1;
*cell = Value::Null;
}
}
let sample = self.options.sample.max(1);
self.rows += 1;
if self.reservoir.len() < sample {
self.reservoir.push(cells);
} else {
let slot = self.next_random() % self.rows;
if let Some(kept) = self.reservoir.get_mut(slot as usize) {
*kept = cells;
}
}
}
pub fn finish(self) -> Profile {
let mut sample = Rows::new(self.names.clone());
let size = self.reservoir.len();
for row in self.reservoir {
sample.push(row);
}
let inference = Inferrer::new()
.null_tokens(self.options.nulls.iter().cloned())
.infer(&sample);
inference.apply(&mut sample);
let stats = Stats::with_options(
&sample,
&StatsOptions {
quantiles: vec![0.25, 0.75],
top: self.options.top,
},
);
let rows = self.rows;
let columns = stats
.columns()
.iter()
.zip(inference.columns())
.enumerate()
.map(|(index, (s, inferred))| {
let mut column = describe(
&sample,
index,
s,
inferred.data_type().name(),
self.options.bins,
);
column.nulls = self.nulls[index];
column.null_rate = if rows == 0 {
0.0
} else {
self.nulls[index] as f64 / rows as f64
};
column
})
.collect();
let mut first = 1;
let buckets = self
.buckets
.into_iter()
.map(|(rows, nulls)| {
let bucket = MissingBucket { first, rows, nulls };
first += rows;
bucket
})
.collect();
Profile {
name: None,
rows,
sample: size,
columns,
missing: MissingMap {
bucket_rows: self.bucket_rows,
buckets,
},
notes: Vec::new(),
paths: None,
malformed: 0,
malformed_lines: Vec::new(),
rules: Rules::default(),
}
}
fn merge_buckets(&mut self) {
let merged = self
.buckets
.chunks(2)
.map(|pair| {
let mut rows = 0;
let mut nulls = vec![0; self.indices.len()];
for (r, n) in pair {
rows += r;
for (total, n) in nulls.iter_mut().zip(n) {
*total += n;
}
}
(rows, nulls)
})
.collect();
self.buckets = merged;
self.bucket_rows *= 2;
}
fn next_random(&mut self) -> u64 {
self.rng = self.rng.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.rng;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
}
fn describe(
sample: &Rows,
index: usize,
s: &ColumnStats,
data_type: &str,
bins: usize,
) -> ColumnProfile {
let distribution = if s.count == 0 {
Distribution::Empty
} else if s.numeric {
let values = sample.column(index).filter_map(|v| {
v.as_f64()
.or_else(|| parse_float(v.plain().trim()))
.filter(|n| n.is_finite())
});
histogram(Histogram::new(values).bins(bins.max(1)))
} else {
let shown: usize = s.top.iter().map(|(_, n)| n).sum();
Distribution::Top {
values: s
.top
.iter()
.map(|(value, count)| TopValue {
value: value.clone(),
count: *count,
})
.collect(),
other: s.count - shown,
}
};
let quantile = |q: f64| s.quantiles.iter().find(|(at, _)| *at == q).map(|v| v.1);
ColumnProfile {
name: s.name.clone(),
data_type: data_type.to_string(),
nulls: 0,
null_rate: 0.0,
count: s.count,
distinct: s.distinct,
min: s.min.clone(),
max: s.max.clone(),
mean: s.mean,
median: s.median,
p25: quantile(0.25),
p75: quantile(0.75),
distribution,
present: None,
absent: None,
values: None,
types: None,
mixed: None,
}
}
fn is_null(tokens: &[String], value: &Value) -> bool {
match value {
Value::Null => true,
Value::Int(_) | Value::Float(_) => false,
Value::Str(text) => tokens.iter().any(|t| t == text.trim()),
Value::Text(text) => {
let plain = text.plain();
tokens.iter().any(|t| t == plain.trim())
}
}
}
fn histogram(histogram: Histogram) -> Distribution {
let edges = histogram.edges();
let counts = histogram.counts();
let chart = histogram.to_bar_chart();
let bins = chart
.bars()
.iter()
.zip(counts)
.enumerate()
.map(|(i, (bar, count))| Bin {
label: bar.label.clone(),
low: edges[i],
high: edges[i + 1],
count,
})
.collect();
Distribution::Histogram { bins }
}
fn label(value: &str) -> String {
let line = sanitize_single_line(value);
if line.chars().count() <= LABEL_CHARS {
return line;
}
let mut cut: String = line.chars().take(LABEL_CHARS - 1).collect();
cut.push('…');
cut
}
fn nulls(count: u64, rate: f64) -> String {
if count == 0 {
"0".to_string()
} else {
format!("{} ({})", grouped(count), percent(rate))
}
}
fn percent(rate: f64) -> String {
let p = rate * 100.0;
if (99.5..100.0).contains(&p) {
return ">99%".to_string();
}
if p > 0.0 && p < 0.1 {
return "<0.1%".to_string();
}
if p < 1.0 {
format!("{}%", number((p * 10.0).round() / 10.0))
} else {
format!("{}%", p.round())
}
}
fn grouped(n: u64) -> String {
let digits = n.to_string();
let mut out = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
fn plural(n: u64, one: &'static str, many: &'static str) -> &'static str {
if n == 1 { one } else { many }
}
#[cfg(test)]
mod tests {
use super::*;
fn profile(columns: &[&str], rows: &[&[&str]], options: ProfileOptions) -> Profile {
let names: Vec<String> = columns.iter().map(|c| c.to_string()).collect();
let mut profiler = Profiler::new(&names, options).unwrap();
for row in rows {
profiler.push(row.iter().map(|c| Value::from(*c)).collect());
}
profiler.finish()
}
#[test]
fn numbers_group_and_rates_round() {
assert_eq!(grouped(0), "0");
assert_eq!(grouped(999), "999");
assert_eq!(grouped(1_000), "1,000");
assert_eq!(grouped(1_234_567), "1,234,567");
assert_eq!(percent(0.25), "25%");
assert_eq!(percent(0.004), "0.4%");
assert_eq!(percent(0.000_01), "<0.1%");
assert_eq!(percent(0.999), ">99%");
assert_eq!(percent(1.0), "100%");
}
#[test]
fn the_map_merges_buckets_in_order() {
let options = ProfileOptions {
buckets: 4,
..Default::default()
};
let rows: Vec<Vec<&str>> = (0..10)
.map(|i| vec![if i % 3 == 0 { "" } else { "x" }])
.collect();
let rows: Vec<&[&str]> = rows.iter().map(Vec::as_slice).collect();
let p = profile(&["a"], &rows, options);
let map = p.missing();
assert_eq!(map.bucket_rows, 4);
let shape: Vec<(u64, u64, Vec<u64>)> = map
.buckets
.iter()
.map(|b| (b.first, b.rows, b.nulls.clone()))
.collect();
assert_eq!(shape, [(1, 4, vec![2]), (5, 4, vec![1]), (9, 2, vec![1])]);
assert_eq!(p.columns()[0].nulls, 4);
}
#[test]
fn a_large_input_is_sampled_with_exact_nulls() {
let options = ProfileOptions {
sample: 50,
..Default::default()
};
let names = vec!["n".to_string(), "s".to_string()];
let mut profiler = Profiler::new(&names, options).unwrap();
for i in 0..5_000i64 {
let s = if i % 10 == 0 {
Value::Null
} else {
Value::from("x")
};
profiler.push(vec![Value::Int(i), s]);
}
let p = profiler.finish();
assert_eq!((p.rows(), p.sample_size(), p.sampled()), (5_000, 50, true));
assert_eq!(p.columns()[1].nulls, 500);
assert_eq!(p.columns()[1].null_rate, 0.1);
let max: f64 = p.columns()[0].max.as_deref().unwrap().parse().unwrap();
assert!(max > 2_500.0, "{max}");
assert_eq!(p.to_json()["sampled"], true);
assert_eq!(p.to_json()["sample"], 50);
}
#[test]
fn ragged_and_empty_input_never_panic() {
let names = vec!["a".to_string(), "b".to_string()];
let mut profiler = Profiler::new(&names, ProfileOptions::default()).unwrap();
profiler.push(vec![]);
profiler.push(vec![Value::from("1"), Value::from("2"), Value::from("3")]);
let p = profiler.finish();
assert_eq!(p.columns()[0].nulls, 1);
let console = Console::builder().width(30).color_system(None).build();
console.render_export(&p);
let empty = Profiler::new(&[], ProfileOptions::default())
.unwrap()
.finish();
assert_eq!(console.render_export(&empty), "0 rows, 0 columns\n");
let none = Profiler::new(&names, ProfileOptions::default())
.unwrap()
.finish();
assert!(console.render_export(&none).contains("no values"));
}
#[test]
fn unknown_columns_are_named() {
let options = ProfileOptions {
columns: Some(vec!["b".into(), "z".into()]),
..Default::default()
};
let error = Profiler::new(&["a".into(), "b".into()], options).unwrap_err();
assert_eq!(error.to_string(), "no column \"z\"; the columns are a, b");
}
#[test]
fn the_model_round_trips_through_json() {
let p = profile(
&["k", "v"],
&[&["a", "1"], &["b", "2.5"], &["a", ""]],
ProfileOptions::default(),
)
.with_name("x.csv");
let json = serde_json::to_string(&p).unwrap();
let back: Profile = serde_json::from_str(&json).unwrap();
assert_eq!(back, p);
assert_eq!(p.columns()[1].data_type, "float");
match &p.columns()[0].distribution {
Distribution::Top { values, other } => {
assert_eq!(values[0].value, "a");
assert_eq!((values[0].count, *other), (2, 0));
}
other => panic!("{other:?}"),
}
}
}