use std::borrow::Cow;
use std::collections::{BTreeSet, HashMap};
use std::io::BufRead;
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use super::{
MissingBucket, MissingMap, Profile, ProfileOptions, Rules, describe, grouped, percent, plural,
};
use crate::infer::Inferrer;
use crate::jsonl::Lines;
use crate::record::cell;
use crate::stats::{Stats, StatsOptions};
use crate::{DataError, Rows, Value};
pub const DEFAULT_DEPTH: usize = 4;
pub const DEFAULT_MAX_PATHS: usize = 200;
pub const MAX_MALFORMED_LINES: usize = 10;
const MAX_DROPPED: usize = crate::record::MAX_UNKNOWN_KEYS;
const NOTE_NAMES: usize = 10;
fn is_zero(n: &u64) -> bool {
*n == 0
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TypeCounts {
#[serde(default, skip_serializing_if = "is_zero")]
pub string: u64,
#[serde(default, skip_serializing_if = "is_zero")]
pub number: u64,
#[serde(default, skip_serializing_if = "is_zero")]
pub boolean: u64,
#[serde(default, skip_serializing_if = "is_zero")]
pub object: u64,
#[serde(default, skip_serializing_if = "is_zero")]
pub array: u64,
#[serde(default, skip_serializing_if = "is_zero")]
pub null: u64,
}
impl TypeCounts {
pub fn add(&mut self, value: &Json) {
let slot = match value {
Json::String(_) => &mut self.string,
Json::Number(_) => &mut self.number,
Json::Bool(_) => &mut self.boolean,
Json::Object(_) => &mut self.object,
Json::Array(_) => &mut self.array,
Json::Null => &mut self.null,
};
*slot += 1;
}
pub fn counts(&self) -> [(&'static str, &'static str, u64); 6] {
[
("string", "str", self.string),
("number", "num", self.number),
("boolean", "bool", self.boolean),
("object", "obj", self.object),
("array", "arr", self.array),
("null", "null", self.null),
]
}
pub fn total(&self) -> u64 {
self.counts().iter().map(|(_, _, n)| n).sum()
}
pub fn kinds(&self) -> usize {
self.counts().iter().filter(|(_, _, n)| *n > 0).count()
}
pub fn mixed(&self) -> bool {
self.counts()
.iter()
.filter(|(name, _, n)| *n > 0 && *name != "null")
.count()
> 1
}
pub fn mix(&self) -> String {
let total = self.total();
let mut seen: Vec<(&str, u64)> = self
.counts()
.iter()
.filter(|(_, _, n)| *n > 0)
.map(|(_, short, n)| (*short, *n))
.collect();
if seen.len() == 1 {
return seen[0].0.to_string();
}
seen.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
seen.iter()
.map(|(short, n)| format!("{short} {}", percent(*n as f64 / total as f64)))
.collect::<Vec<_>>()
.join(" ยท ")
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PathSummary {
pub depth: usize,
pub max_paths: usize,
pub sampled: bool,
pub capped: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dropped: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub more_dropped: bool,
}
pub fn path_key(key: &str) -> Cow<'_, str> {
let bare = !key.is_empty()
&& key.trim() == key
&& !key.contains(|c: char| matches!(c, '.' | '[' | ']' | '"' | '\\') || c.is_control());
if bare {
Cow::Borrowed(key)
} else {
let quoted = serde_json::to_string(key).unwrap_or_else(|_| format!("{key:?}"));
Cow::Owned(format!("[{quoted}]"))
}
}
#[derive(Clone, Copy)]
enum Step<'a> {
Key(&'a str),
Item,
}
fn join(parent: Option<&str>, step: Step<'_>) -> String {
let parent = parent.unwrap_or("");
match step {
Step::Item => format!("{parent}[]"),
Step::Key(key) => match path_key(key) {
Cow::Borrowed(bare) if parent.is_empty() => bare.to_string(),
Cow::Borrowed(bare) => format!("{parent}.{bare}"),
Cow::Owned(quoted) => format!("{parent}{quoted}"),
},
}
}
#[derive(Clone, Debug)]
struct Node {
name: String,
parent: Option<usize>,
keys: HashMap<String, usize>,
item: Option<usize>,
present: u64,
last_present: u64,
last_value: u64,
values: u64,
types: TypeCounts,
seen: u64,
reservoir: Vec<Value>,
}
impl Node {
fn new(name: String, parent: Option<usize>) -> Self {
Node {
name,
parent,
keys: HashMap::new(),
item: None,
present: 0,
last_present: 0,
last_value: 0,
values: 0,
types: TypeCounts::default(),
seen: 0,
reservoir: Vec::new(),
}
}
}
#[derive(Clone, Debug)]
pub struct JsonProfiler {
options: ProfileOptions,
rows: u64,
nodes: Vec<Node>,
top: HashMap<String, usize>,
rng: u64,
bucket_rows: u64,
buckets: Vec<(u64, Vec<u64>)>,
dropped: BTreeSet<String>,
more_dropped: bool,
malformed: u64,
malformed_lines: Vec<usize>,
first_malformed: Option<(usize, String)>,
}
impl JsonProfiler {
pub fn new(options: ProfileOptions) -> Self {
JsonProfiler {
options,
rows: 0,
nodes: Vec::new(),
top: HashMap::new(),
rng: 0x9E37_79B9_7F4A_7C15,
bucket_rows: 1,
buckets: Vec::new(),
dropped: BTreeSet::new(),
more_dropped: false,
malformed: 0,
malformed_lines: Vec::new(),
first_malformed: None,
}
}
pub fn push(&mut self, record: &Json) {
self.rows += 1;
let depth = self.options.depth.max(1);
let mut stack: Vec<(Option<usize>, Step<'_>, &Json, usize)> = match record {
Json::Object(map) => map
.iter()
.rev()
.map(|(key, value)| (None, Step::Key(key.as_str()), value, 1))
.collect(),
other => vec![(None, Step::Key("value"), other, 1)],
};
while let Some((parent, step, value, level)) = stack.pop() {
let Some(id) = self.node(parent, step) else {
continue;
};
let expand = level < depth;
self.observe(id, value, expand);
if !expand {
continue;
}
match value {
Json::Object(map) => stack.extend(
map.iter()
.rev()
.map(|(key, value)| (Some(id), Step::Key(key.as_str()), value, level + 1)),
),
Json::Array(items) => stack.extend(
items
.iter()
.rev()
.map(|item| (Some(id), Step::Item, item, level + 1)),
),
_ => {}
}
}
self.end_record();
}
pub fn skip_malformed(&mut self, line: usize, message: impl Into<String>) {
self.malformed += 1;
if self.malformed_lines.len() < MAX_MALFORMED_LINES {
self.malformed_lines.push(line);
}
if self.first_malformed.is_none() {
self.first_malformed = Some((line, message.into()));
}
}
fn node(&mut self, parent: Option<usize>, step: Step<'_>) -> Option<usize> {
let found = match (parent, step) {
(None, Step::Key(key)) => self.top.get(key).copied(),
(Some(parent), Step::Key(key)) => self.nodes[parent].keys.get(key).copied(),
(Some(parent), Step::Item) => self.nodes[parent].item,
(None, Step::Item) => return None,
};
if found.is_some() {
return found;
}
let name = join(parent.map(|p| self.nodes[p].name.as_str()), step);
if self.nodes.len() >= self.options.max_paths.max(1) {
if !self.dropped.contains(&name) {
if self.dropped.len() < MAX_DROPPED {
self.dropped.insert(name);
} else {
self.more_dropped = true;
}
}
return None;
}
let id = self.nodes.len();
for (rows, missing) in &mut self.buckets {
missing.push(*rows);
}
self.nodes.push(Node::new(name, parent));
match (parent, step) {
(None, Step::Key(key)) => {
self.top.insert(key.to_string(), id);
}
(Some(parent), Step::Key(key)) => {
self.nodes[parent].keys.insert(key.to_string(), id);
}
(Some(parent), Step::Item) => self.nodes[parent].item = Some(id),
(None, Step::Item) => {}
}
Some(id)
}
fn observe(&mut self, id: usize, value: &Json, expand: bool) {
let record = self.rows;
let node = &mut self.nodes[id];
node.values += 1;
node.types.add(value);
if node.last_present != record {
node.last_present = record;
node.present += 1;
}
if value.is_null() {
return;
}
node.last_value = record;
if expand && matches!(value, Json::Object(_) | Json::Array(_)) {
return;
}
node.seen += 1;
let sample = self.options.sample.max(1);
let (seen, kept) = (node.seen, node.reservoir.len());
if kept < sample {
self.nodes[id].reservoir.push(cell(value));
return;
}
let slot = self.next_random() % seen;
if let Some(kept) = self.nodes[id].reservoir.get_mut(slot as usize) {
*kept = cell(value);
}
}
fn end_record(&mut self) {
let record = self.rows;
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.nodes.len()]));
}
}
let bucket = self.buckets.last_mut().expect("a bucket was just added");
bucket.0 += 1;
for (missing, node) in bucket.1.iter_mut().zip(&self.nodes) {
if node.last_value != record {
*missing += 1;
}
}
}
fn merge_buckets(&mut self) {
let width = self.nodes.len();
self.buckets = self
.buckets
.chunks(2)
.map(|pair| {
let mut rows = 0;
let mut missing = vec![0; width];
for (r, m) in pair {
rows += r;
for (total, n) in missing.iter_mut().zip(m) {
*total += n;
}
}
(rows, missing)
})
.collect();
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 tree_order(&self) -> Vec<usize> {
let mut children: Vec<Vec<usize>> = vec![Vec::new(); self.nodes.len()];
let mut stack: Vec<usize> = Vec::new();
for (id, node) in self.nodes.iter().enumerate() {
match node.parent {
Some(parent) => children[parent].push(id),
None => stack.push(id),
}
}
stack.reverse();
let mut order = Vec::with_capacity(self.nodes.len());
while let Some(id) = stack.pop() {
order.push(id);
stack.extend(children[id].iter().rev());
}
order
}
pub fn finish(mut self) -> Result<Profile, DataError> {
let order = match &self.options.columns {
None => self.tree_order(),
Some(wanted) => wanted
.iter()
.map(|name| {
self.nodes
.iter()
.position(|node| &node.name == name)
.ok_or_else(|| self.unknown_path(name))
})
.collect::<Result<_, _>>()?,
};
let rows = self.rows;
let sample = self.options.sample.max(1);
let sampled = self.nodes.iter().any(|n| n.seen > n.reservoir.len() as u64);
let stats_options = StatsOptions {
quantiles: vec![0.25, 0.75],
top: self.options.top,
};
let mut columns = Vec::with_capacity(order.len());
for &id in &order {
let node = &mut self.nodes[id];
let mut values = Rows::new([node.name.clone()]);
for value in std::mem::take(&mut node.reservoir) {
values.push([value]);
}
let inference = Inferrer::new()
.null_tokens(std::iter::empty::<String>())
.infer(&values);
inference.apply(&mut values);
let stats = Stats::with_options(&values, &stats_options);
let s = &stats.columns()[0];
let types = node.types;
let data_type = if s.count == 0 && types.object + types.array > 0 {
if types.object >= types.array {
"object"
} else {
"array"
}
} else {
inference.columns()[0].data_type().name()
};
let mut column = describe(&values, 0, s, data_type, self.options.bins);
column.nulls = types.null;
column.null_rate = if node.values == 0 {
0.0
} else {
types.null as f64 / node.values as f64
};
column.present = Some(node.present);
column.absent = Some(rows - node.present);
column.values = Some(node.values);
column.types = Some(types);
column.mixed = Some(types.mixed());
columns.push(column);
}
let mut first = 1;
let buckets = self
.buckets
.iter()
.map(|(records, missing)| {
let bucket = MissingBucket {
first,
rows: *records,
nulls: order.iter().map(|&id| missing[id]).collect(),
};
first += records;
bucket
})
.collect();
let mut notes = Vec::new();
let mixed: Vec<&str> = columns
.iter()
.filter(|c| c.mixed == Some(true))
.map(|c| c.name.as_str())
.collect();
if !mixed.is_empty() {
let count = mixed.len() as u64;
notes.push(format!(
"! marks {count} {} holding more than one JSON type besides null: {}",
plural(count, "path", "paths"),
listed(&mixed, count as usize)
));
}
if !self.dropped.is_empty() {
let names: Vec<&str> = self.dropped.iter().map(String::as_str).collect();
let count = names.len() as u64;
let shown = if self.more_dropped {
format!("{}+", grouped(count))
} else {
grouped(count)
};
notes.push(format!(
"max_paths ({}) was reached: {shown} more {} not profiled: {}",
grouped(self.options.max_paths.max(1) as u64),
plural(count, "path is", "paths are"),
listed(
&names,
if self.more_dropped {
usize::MAX
} else {
names.len()
}
)
));
}
if self.malformed > 0 {
let mut lines: Vec<String> =
self.malformed_lines.iter().map(usize::to_string).collect();
if self.malformed as usize > lines.len() {
lines.push("โฆ".into());
}
let mut note = format!(
"{} {} not JSON and {} skipped: {} {}",
grouped(self.malformed),
plural(self.malformed, "line is", "lines are"),
plural(self.malformed, "was", "were"),
plural(self.malformed, "line", "lines"),
lines.join(", "),
);
if let Some((line, message)) = &self.first_malformed {
let why = message.strip_prefix("not JSON: ").unwrap_or(message);
let why = why.split(" at line ").next().unwrap_or(why);
if self.malformed == 1 {
note.push_str(&format!(" ({why})"));
} else {
note.push_str(&format!(" (line {line}: {why})"));
}
}
notes.push(note);
}
Ok(Profile {
name: None,
rows,
sample,
columns,
missing: MissingMap {
bucket_rows: self.bucket_rows,
buckets,
},
notes,
paths: Some(PathSummary {
depth: self.options.depth.max(1),
max_paths: self.options.max_paths.max(1),
sampled,
capped: !self.dropped.is_empty(),
dropped: self.dropped.into_iter().collect(),
more_dropped: self.more_dropped,
}),
malformed: self.malformed,
malformed_lines: self.malformed_lines,
rules: Rules::default(),
})
}
fn unknown_path(&self, name: &str) -> DataError {
let names: Vec<&str> = self.nodes.iter().map(|n| n.name.as_str()).collect();
let mut message = format!(
"no path {:?}; the paths are {}",
rich_ext::sanitize::sanitize_single_line(name),
listed(&names, names.len())
);
if !self.dropped.is_empty() {
message.push_str(&format!(
" (max_paths {} was reached; a path seen later is not profiled)",
self.options.max_paths.max(1)
));
}
DataError::new(message)
}
}
fn listed(names: &[&str], total: usize) -> String {
let mut shown: Vec<String> = names
.iter()
.take(NOTE_NAMES)
.map(|n| rich_ext::sanitize::sanitize_single_line(n))
.collect();
if total > shown.len() {
shown.push("โฆ".into());
}
shown.join(", ")
}
impl Profile {
pub fn from_json_lines(
reader: impl BufRead,
options: ProfileOptions,
) -> Result<Self, DataError> {
let mut profiler = JsonProfiler::new(options);
let mut lines = Lines::new(reader);
while let Some(record) = lines.next() {
match record {
Ok(record) => profiler.push(&record),
Err(error) if !lines.stopped() => {
profiler.skip_malformed(error.line().unwrap_or(0), error.message())
}
Err(error) => return Err(error),
}
}
profiler.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rich::Console;
use serde_json::json;
fn nested(text: &str, options: ProfileOptions) -> Profile {
Profile::from_json_lines(text.as_bytes(), options).unwrap()
}
fn names(profile: &Profile) -> Vec<&str> {
profile.columns().iter().map(|c| c.name.as_str()).collect()
}
#[test]
fn paths_follow_objects_and_arrays_in_tree_order() {
let p = nested(
"{\"id\": 1, \"items\": [{\"sku\": \"a\", \"qty\": 2}]}\n\
{\"id\": 2, \"user\": {\"name\": \"x\"}, \"items\": [{\"sku\": \"b\", \"note\": \"n\"}]}\n",
ProfileOptions::default(),
);
assert_eq!(
names(&p),
[
"id",
"items",
"items[]",
"items[].sku",
"items[].qty",
"items[].note",
"user",
"user.name"
]
);
assert_eq!(p.heading(), "2 records, 8 paths");
let sku = p.column("items[].sku").unwrap();
assert_eq!(
(sku.present, sku.absent, sku.values),
(Some(2), Some(0), Some(2))
);
let items = p.column("items").unwrap();
assert_eq!(items.data_type, "array");
assert_eq!(p.column("items[]").unwrap().data_type, "object");
}
#[test]
fn array_items_count_every_value_and_presence_counts_records() {
let p = nested(
"{\"tags\": [\"a\", \"b\", \"a\"]}\n{\"tags\": []}\n{}\n",
ProfileOptions::default(),
);
let tags = p.column("tags[]").unwrap();
assert_eq!(
(tags.values, tags.present, tags.absent),
(Some(3), Some(1), Some(2))
);
assert_eq!((tags.count, tags.distinct), (3, 2));
let tags = p.column("tags").unwrap();
assert_eq!((tags.present, tags.absent), (Some(2), Some(1)));
}
#[test]
fn absent_keys_are_counted_apart_from_null() {
let p = nested(
"{\"a\": 1, \"b\": null}\n{\"a\": null}\n{\"a\": 2, \"b\": \"x\"}\n{}\n",
ProfileOptions::default(),
);
let a = p.column("a").unwrap();
assert_eq!((a.present, a.absent, a.nulls), (Some(3), Some(1), 1));
assert_eq!(a.null_rate, 1.0 / 3.0);
let b = p.column("b").unwrap();
assert_eq!((b.present, b.absent, b.nulls), (Some(2), Some(2), 1));
assert_eq!(b.types.unwrap().mix(), "str 50% ยท null 50%");
assert_eq!(b.mixed, Some(false));
let map = p.missing();
let b_missing: Vec<u64> = map.buckets.iter().map(|bucket| bucket.nulls[1]).collect();
assert_eq!(b_missing, [1, 1, 0, 1]);
let json = p.to_json();
assert_eq!(json["columns"][1]["absent"], 2);
assert_eq!(json["columns"][1]["types"], json!({"string": 1, "null": 1}));
}
#[test]
fn type_mixes_count_each_json_type() {
let p = nested(
"{\"v\": 1}\n{\"v\": \"1\"}\n{\"v\": true}\n{\"v\": [1]}\n{\"v\": {\"k\": 1}}\n\
{\"v\": null}\n{\"v\": 2.5}\n",
ProfileOptions::default(),
);
let v = p.column("v").unwrap();
let types = v.types.unwrap();
assert_eq!(
(
types.string,
types.number,
types.boolean,
types.object,
types.array,
types.null
),
(1, 2, 1, 1, 1, 1)
);
assert_eq!(types.kinds(), 6);
assert!(types.mixed());
assert_eq!(
types.mix(),
"num 29% ยท str 14% ยท bool 14% ยท obj 14% ยท arr 14% ยท null 14%"
);
let only = TypeCounts {
number: 3,
..Default::default()
};
assert_eq!((only.mix(), only.mixed()), ("num".to_string(), false));
assert_eq!(TypeCounts::default().mix(), "");
let table = Console::builder()
.width(160)
.color_system(None)
.build()
.render_export(&p.to_table());
assert!(table.contains("! num 29%"), "{table}");
assert!(p.notes()[0].starts_with("! marks 1 path holding more than one JSON type"));
}
#[test]
fn depth_bounds_the_walk_and_unwalked_values_are_json_text() {
let text = "{\"a\": {\"b\": {\"c\": {\"d\": {\"e\": 1}}}}}\n";
let p = nested(text, ProfileOptions::default());
assert_eq!(names(&p), ["a", "a.b", "a.b.c", "a.b.c.d"]);
let deepest = p.column("a.b.c.d").unwrap();
assert_eq!(deepest.max.as_deref(), Some("{\"e\":1}"));
let shallow = nested(
text,
ProfileOptions {
depth: 1,
..Default::default()
},
);
assert_eq!(names(&shallow), ["a"]);
assert_eq!(shallow.paths().unwrap().depth, 1);
let mut deep = json!(1);
for _ in 0..100_000 {
deep = Json::Array(vec![deep]);
}
let mut profiler = JsonProfiler::new(ProfileOptions {
depth: usize::MAX,
max_paths: 10,
..Default::default()
});
profiler.push(&deep);
let p = profiler.finish().unwrap();
assert_eq!(p.columns().len(), 10);
assert!(p.paths().unwrap().capped);
let mut value = deep;
while let Json::Array(mut items) = value {
value = items.pop().unwrap_or(Json::Null);
}
}
#[test]
fn the_path_cap_names_what_it_dropped_and_stays_bounded() {
let text: String = (0..1_000)
.map(|i| format!("{{\"id\": {i}, \"k{i:04}\": 1}}\n"))
.collect();
let p = nested(
&text,
ProfileOptions {
max_paths: 5,
..Default::default()
},
);
assert_eq!(names(&p), ["id", "k0000", "k0001", "k0002", "k0003"]);
let paths = p.paths().unwrap();
assert!(paths.capped && paths.more_dropped);
assert_eq!(paths.dropped.len(), 100);
assert_eq!(paths.dropped[0], "k0004");
let note = p
.notes()
.iter()
.find(|n| n.starts_with("max_paths"))
.unwrap();
assert!(
note.starts_with(
"max_paths (5) was reached: 100+ more paths are not profiled: k0004, "
),
"{note}"
);
assert!(note.ends_with(", โฆ"), "{note}");
let table = Console::builder()
.width(160)
.color_system(None)
.build()
.render_export(&p.to_table());
assert!(
table.contains("100+ more paths not profiled (max_paths 5)"),
"{table}"
);
let json = p.to_json();
assert_eq!(json["paths"]["capped"], true);
assert_eq!(json["paths"]["max_paths"], 5);
let error = Profile::from_json_lines(
text.as_bytes(),
ProfileOptions {
max_paths: 5,
columns: Some(vec!["k0500".into()]),
..Default::default()
},
)
.unwrap_err();
assert!(
error
.message()
.starts_with("no path \"k0500\"; the paths are id, k0000")
);
assert!(
error.message().contains("max_paths 5 was reached"),
"{error}"
);
}
#[test]
fn keys_that_look_like_paths_are_quoted() {
let p = nested(
"{\"a.b\": {\"c\": 1}, \"x[0]\": [true], \"\": 1, \" pad\": 2, \"q\\\"\": {\"k8s.io/n\": 3}}\n",
ProfileOptions::default(),
);
assert_eq!(
names(&p),
[
"[\"a.b\"]",
"[\"a.b\"].c",
"[\"x[0]\"]",
"[\"x[0]\"][]",
"[\"\"]",
"[\" pad\"]",
"[\"q\\\"\"]",
"[\"q\\\"\"][\"k8s.io/n\"]"
]
);
let p = nested(
"{\"a.b\": {\"c\": 1}}\n",
ProfileOptions {
columns: Some(vec!["[\"a.b\"].c".into()]),
..Default::default()
},
);
assert_eq!(names(&p), ["[\"a.b\"].c"]);
}
#[test]
fn malformed_lines_are_counted_and_never_fatal() {
let mut text = String::from("{\"a\": 1}\n{\"a\": \nnot json\n\u{0}\u{1}\n[1, 2\n");
for i in 0..20 {
text.push_str(&format!("{{oops {i}\n"));
}
text.push_str("{\"a\": 2}\n\"just a string\"\n7\n");
let mut bytes = text.into_bytes();
bytes.extend_from_slice(b"{\"a\": \"\xff\"}\n");
let p = Profile::from_json_lines(&bytes[..], ProfileOptions::default()).unwrap();
assert_eq!(p.rows(), 4);
assert_eq!(p.malformed(), 25);
assert_eq!(p.malformed_lines(), [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
let note = p.notes().last().unwrap();
assert!(
note.starts_with("25 lines are not JSON and were skipped: lines 2, 3,"),
"{note}"
);
assert!(
note.ends_with(", 11, โฆ (line 2: EOF while parsing a value)"),
"{note}"
);
assert_eq!(names(&p), ["a", "value"]);
assert_eq!(
p.column("value").unwrap().types.unwrap().mix(),
"str 50% ยท num 50%"
);
let json = p.to_json();
assert_eq!(json["malformed"], 25);
assert_eq!(json["malformed_lines"][0], 2);
let p = nested("nope\n{\n", ProfileOptions::default());
assert_eq!((p.rows(), p.columns().len(), p.malformed()), (0, 0, 2));
let console = Console::builder().width(40).color_system(None).build();
console.render_export(&p);
struct Broken(bool);
impl std::io::Read for Broken {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if std::mem::replace(&mut self.0, true) {
return Err(std::io::Error::other("disk on fire"));
}
let line = b"{\"a\": 1}\n";
buf[..line.len()].copy_from_slice(line);
Ok(line.len())
}
}
let error = Profile::from_json_lines(
std::io::BufReader::new(Broken(false)),
ProfileOptions::default(),
)
.unwrap_err();
assert_eq!((error.line(), error.message()), (Some(2), "disk on fire"));
}
#[test]
fn a_long_stream_is_sampled_per_path_with_exact_counts() {
let options = ProfileOptions {
sample: 100,
buckets: 8,
..Default::default()
};
let mut profiler = JsonProfiler::new(options);
for i in 0..20_000i64 {
let record = if i % 4 == 0 {
json!({"n": i, "xs": [i, i + 1]})
} else {
json!({"n": i, "s": null})
};
profiler.push(&record);
}
let p = profiler.finish().unwrap();
assert!(p.sampled());
assert_eq!(p.sample_size(), 100);
assert_eq!(
p.heading(),
"20,000 records, 4 paths, sampled to 100 values a path"
);
let n = p.column("n").unwrap();
assert_eq!((n.values, n.count), (Some(20_000), 100));
let xs = p.column("xs[]").unwrap();
assert_eq!(
(xs.values, xs.present, xs.absent),
(Some(10_000), Some(5_000), Some(15_000))
);
let s = p.column("s").unwrap();
assert_eq!((s.nulls, s.null_rate, s.absent), (15_000, 1.0, Some(5_000)));
assert!(p.missing().buckets.len() <= 8);
let records: u64 = p.missing().buckets.iter().map(|b| b.rows).sum();
assert_eq!(records, 20_000);
let s_missing: u64 = p.missing().buckets.iter().map(|b| b.nulls[3]).sum();
assert_eq!(s_missing, 20_000);
let again = {
let mut profiler = JsonProfiler::new(ProfileOptions {
sample: 100,
buckets: 8,
..Default::default()
});
for i in 0..20_000i64 {
profiler.push(&if i % 4 == 0 {
json!({"n": i, "xs": [i, i + 1]})
} else {
json!({"n": i, "s": null})
});
}
profiler.finish().unwrap()
};
assert_eq!(again, p);
}
#[test]
fn the_nested_model_round_trips_and_renders_narrow() {
let p = nested(
"{\"a\": {\"b\": [1, \"x\"]}, \"c\": null}\n{\"a\": 3}\nnope\n",
ProfileOptions::default(),
)
.with_name("n.jsonl");
let json = serde_json::to_string(&p).unwrap();
let back: Profile = serde_json::from_str(&json).unwrap();
assert_eq!(back, p);
for width in [40, 50, 80] {
let console = Console::builder().width(width).color_system(None).build();
let segments = console.render(&p, None);
for line in rich::Segment::split_lines(&segments) {
let cells: usize = line.iter().map(rich::Segment::cell_length).sum();
assert!(cells <= width, "{width}: {line:?}");
}
}
}
}