Skip to main content

rich_data/profile/
nested.rs

1//! Nested profiles of JSON records (#705): every path to a value, how many
2//! records hold it, and the mix of JSON types found there.
3//!
4//! A [`JsonProfiler`] walks each record (one JSON value per line, as
5//! [`jsonl`](crate::jsonl) reads them) and profiles each **path** as a
6//! column: `user.id` for a key inside an object, `tags[]` for the items of an
7//! array and `items[].sku` for a key of the objects in one. A record that is
8//! not an object is the path `value`, as in a flat profile.
9//!
10//! **Path syntax.** A key is written as it is unless it is empty, has
11//! whitespace at either end, or holds `.`, `[`, `]`, `"`, `\` or a control
12//! character; then it is a JSON string in brackets, as JSONPath and jq write
13//! it: `meta["k8s.io/name"]`, `["a.b"].c`, `[""]`. [`path_key`] writes one.
14//!
15//! **Bounded.** A path has at most [`ProfileOptions::depth`] steps (a key or
16//! `[]`; default [`DEFAULT_DEPTH`]): an object or array at the last step is
17//! not walked into, and its compact JSON text is its value, as in a flat
18//! profile. At most [`ProfileOptions::max_paths`] paths are profiled
19//! (default [`DEFAULT_MAX_PATHS`]), in the order they are first seen; paths
20//! seen after that are named (the first hundred) and noted, and nothing
21//! below them is walked. Each path keeps at most [`ProfileOptions::sample`]
22//! of its values, a fixed-seed reservoir as [`Profiler`](super::Profiler)
23//! keeps rows. Presence, type counts, nulls and the missing-value map count
24//! every record. Time is linear in the input; memory is the paths and their
25//! samples, however long it is.
26//!
27//! **Presence and types.** For each path the profile counts the records
28//! that hold it ([`ColumnProfile::present`](super::ColumnProfile::present))
29//! apart from those that do not
30//! ([`absent`](super::ColumnProfile::absent)); an explicit `null` is
31//! present, and counted in `nulls`. It counts each value's JSON type
32//! ([`TypeCounts`]: string, number, boolean, object, array, null), so a
33//! field that is sometimes a number and sometimes a string says so instead
34//! of being one inferred type; such a path is **mixed**. Only JSON's `null`
35//! is null: the null tokens of a flat profile (`NA`, an empty string) are
36//! strings here.
37//!
38//! ```
39//! use rich_data::profile::{Profile, ProfileOptions};
40//!
41//! let text = r#"{"id": 1, "user": {"name": "ada"}, "tags": ["a", "b"]}
42//! {"id": "2", "user": null}
43//! not json
44//! {"id": 3, "tags": []}
45//! "#;
46//! let profile = Profile::from_json_lines(text.as_bytes(), ProfileOptions::default()).unwrap();
47//! assert!(profile.is_nested());
48//! let names: Vec<&str> = profile.columns().iter().map(|c| c.name.as_str()).collect();
49//! assert_eq!(names, ["id", "user", "user.name", "tags", "tags[]"]);
50//!
51//! let id = profile.column("id").unwrap();
52//! assert_eq!(id.types.unwrap().mix(), "num 67% · str 33%");
53//! assert_eq!(id.mixed, Some(true));
54//! let user = profile.column("user").unwrap();
55//! assert_eq!((user.present, user.absent, user.nulls), (Some(2), Some(1), 1));
56//! assert_eq!(profile.column("tags[]").unwrap().values, Some(2));
57//! assert_eq!((profile.malformed(), profile.malformed_lines()), (1, &[3][..]));
58//! ```
59
60use std::borrow::Cow;
61use std::collections::{BTreeSet, HashMap};
62use std::io::BufRead;
63
64use serde::{Deserialize, Serialize};
65use serde_json::Value as Json;
66
67use super::{
68    MissingBucket, MissingMap, Profile, ProfileOptions, Rules, describe, grouped, percent, plural,
69};
70use crate::infer::Inferrer;
71use crate::jsonl::Lines;
72use crate::record::cell;
73use crate::stats::{Stats, StatsOptions};
74use crate::{DataError, Rows, Value};
75
76/// The deepest path a nested profile describes by default, in steps.
77pub const DEFAULT_DEPTH: usize = 4;
78/// The most paths a nested profile describes by default.
79pub const DEFAULT_MAX_PATHS: usize = 200;
80/// The most malformed line numbers a profile keeps.
81pub const MAX_MALFORMED_LINES: usize = 10;
82/// The most paths past the cap a profile names.
83const MAX_DROPPED: usize = crate::record::MAX_UNKNOWN_KEYS;
84/// The most names a note lists.
85const NOTE_NAMES: usize = 10;
86
87fn is_zero(n: &u64) -> bool {
88    *n == 0
89}
90
91/// How many values at a path were each JSON type. Serialised with only the
92/// types seen.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
94pub struct TypeCounts {
95    /// Strings.
96    #[serde(default, skip_serializing_if = "is_zero")]
97    pub string: u64,
98    /// Numbers.
99    #[serde(default, skip_serializing_if = "is_zero")]
100    pub number: u64,
101    /// `true` and `false`.
102    #[serde(default, skip_serializing_if = "is_zero")]
103    pub boolean: u64,
104    /// Objects.
105    #[serde(default, skip_serializing_if = "is_zero")]
106    pub object: u64,
107    /// Arrays.
108    #[serde(default, skip_serializing_if = "is_zero")]
109    pub array: u64,
110    /// `null`.
111    #[serde(default, skip_serializing_if = "is_zero")]
112    pub null: u64,
113}
114
115impl TypeCounts {
116    /// Count one more value.
117    pub fn add(&mut self, value: &Json) {
118        let slot = match value {
119            Json::String(_) => &mut self.string,
120            Json::Number(_) => &mut self.number,
121            Json::Bool(_) => &mut self.boolean,
122            Json::Object(_) => &mut self.object,
123            Json::Array(_) => &mut self.array,
124            Json::Null => &mut self.null,
125        };
126        *slot += 1;
127    }
128
129    /// Each type's name (as JSON's: `string`, `number`, `boolean`, `object`,
130    /// `array`, `null`), its short name for the mix (`str`, `num`, `bool`,
131    /// `obj`, `arr`, `null`) and its count, in that order.
132    pub fn counts(&self) -> [(&'static str, &'static str, u64); 6] {
133        [
134            ("string", "str", self.string),
135            ("number", "num", self.number),
136            ("boolean", "bool", self.boolean),
137            ("object", "obj", self.object),
138            ("array", "arr", self.array),
139            ("null", "null", self.null),
140        ]
141    }
142
143    /// Every value counted.
144    pub fn total(&self) -> u64 {
145        self.counts().iter().map(|(_, _, n)| n).sum()
146    }
147
148    /// How many types were seen, `null` included.
149    pub fn kinds(&self) -> usize {
150        self.counts().iter().filter(|(_, _, n)| *n > 0).count()
151    }
152
153    /// Whether more than one type besides `null` was seen.
154    pub fn mixed(&self) -> bool {
155        self.counts()
156            .iter()
157            .filter(|(name, _, n)| *n > 0 && *name != "null")
158            .count()
159            > 1
160    }
161
162    /// The mix in short: the one type's short name (`str`), or each type
163    /// seen with its share, most common first (`str 90% · null 10%`); empty
164    /// when nothing was counted.
165    pub fn mix(&self) -> String {
166        let total = self.total();
167        let mut seen: Vec<(&str, u64)> = self
168            .counts()
169            .iter()
170            .filter(|(_, _, n)| *n > 0)
171            .map(|(_, short, n)| (*short, *n))
172            .collect();
173        if seen.len() == 1 {
174            return seen[0].0.to_string();
175        }
176        // Stable: ties keep the fixed order.
177        seen.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
178        seen.iter()
179            .map(|(short, n)| format!("{short} {}", percent(*n as f64 / total as f64)))
180            .collect::<Vec<_>>()
181            .join(" · ")
182    }
183}
184
185/// A nested profile's limits, and what met them.
186#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
187pub struct PathSummary {
188    /// The deepest path described, in steps ([`ProfileOptions::depth`]).
189    pub depth: usize,
190    /// The most paths described ([`ProfileOptions::max_paths`]).
191    pub max_paths: usize,
192    /// Whether any path kept fewer of its values than it had.
193    pub sampled: bool,
194    /// Whether paths were seen past `max_paths`.
195    pub capped: bool,
196    /// The first paths seen past it (at most a hundred), sorted.
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub dropped: Vec<String>,
199    /// Whether more were seen than `dropped` names.
200    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
201    pub more_dropped: bool,
202}
203
204/// A key as a path writes it: as it is, or as a JSON string in brackets
205/// (`["k8s.io/name"]`) when it is empty, has whitespace at either end, or
206/// holds `.`, `[`, `]`, `"`, `\` or a control character. See the
207/// [module docs](self).
208///
209/// ```
210/// use rich_data::profile::path_key;
211///
212/// assert_eq!(path_key("user"), "user");
213/// assert_eq!(path_key("first name"), "first name");
214/// assert_eq!(path_key("k8s.io/name"), r#"["k8s.io/name"]"#);
215/// assert_eq!(path_key(""), r#"[""]"#);
216/// ```
217pub fn path_key(key: &str) -> Cow<'_, str> {
218    let bare = !key.is_empty()
219        && key.trim() == key
220        && !key.contains(|c: char| matches!(c, '.' | '[' | ']' | '"' | '\\') || c.is_control());
221    if bare {
222        Cow::Borrowed(key)
223    } else {
224        let quoted = serde_json::to_string(key).unwrap_or_else(|_| format!("{key:?}"));
225        Cow::Owned(format!("[{quoted}]"))
226    }
227}
228
229/// One step down a path.
230#[derive(Clone, Copy)]
231enum Step<'a> {
232    Key(&'a str),
233    Item,
234}
235
236/// `parent`'s path with `step` after it.
237fn join(parent: Option<&str>, step: Step<'_>) -> String {
238    let parent = parent.unwrap_or("");
239    match step {
240        Step::Item => format!("{parent}[]"),
241        Step::Key(key) => match path_key(key) {
242            Cow::Borrowed(bare) if parent.is_empty() => bare.to_string(),
243            Cow::Borrowed(bare) => format!("{parent}.{bare}"),
244            Cow::Owned(quoted) => format!("{parent}{quoted}"),
245        },
246    }
247}
248
249/// What a [`JsonProfiler`] knows of one path.
250#[derive(Clone, Debug)]
251struct Node {
252    name: String,
253    parent: Option<usize>,
254    /// The paths one key further down.
255    keys: HashMap<String, usize>,
256    /// The path of this one's array items.
257    item: Option<usize>,
258    /// Records that hold the path.
259    present: u64,
260    /// The last record counted in `present`.
261    last_present: u64,
262    /// The last record with a value here that is not `null`.
263    last_value: u64,
264    /// Values seen, `null` included.
265    values: u64,
266    types: TypeCounts,
267    /// Values offered to the reservoir.
268    seen: u64,
269    reservoir: Vec<Value>,
270}
271
272impl Node {
273    fn new(name: String, parent: Option<usize>) -> Self {
274        Node {
275            name,
276            parent,
277            keys: HashMap::new(),
278            item: None,
279            present: 0,
280            last_present: 0,
281            last_value: 0,
282            values: 0,
283            types: TypeCounts::default(),
284            seen: 0,
285            reservoir: Vec::new(),
286        }
287    }
288}
289
290/// Profiles JSON records path by path in bounded memory. See the
291/// [module docs](self).
292///
293/// ```
294/// use rich_data::profile::{JsonProfiler, ProfileOptions};
295/// use serde_json::json;
296///
297/// let options = ProfileOptions { depth: 1, ..Default::default() };
298/// let mut profiler = JsonProfiler::new(options);
299/// profiler.push(&json!({"a": {"b": 1}}));
300/// profiler.push(&json!({"a": "x"}));
301/// let profile = profiler.finish().unwrap();
302/// let a = profile.column("a").unwrap();
303/// // One step deep: the object is a value of `a`, as its JSON text.
304/// assert_eq!(a.types.unwrap().mix(), "str 50% · obj 50%");
305/// assert_eq!(a.distinct, 2);
306/// ```
307#[derive(Clone, Debug)]
308pub struct JsonProfiler {
309    options: ProfileOptions,
310    rows: u64,
311    nodes: Vec<Node>,
312    /// The top-level paths, by key.
313    top: HashMap<String, usize>,
314    rng: u64,
315    bucket_rows: u64,
316    /// Per bucket: its records, and per path the records missing it.
317    buckets: Vec<(u64, Vec<u64>)>,
318    dropped: BTreeSet<String>,
319    more_dropped: bool,
320    malformed: u64,
321    malformed_lines: Vec<usize>,
322    first_malformed: Option<(usize, String)>,
323}
324
325impl JsonProfiler {
326    /// A profiler with `options` (`depth`, `max_paths`, `sample`, `top`,
327    /// `bins`, `buckets` and `columns`, which names paths).
328    pub fn new(options: ProfileOptions) -> Self {
329        JsonProfiler {
330            options,
331            rows: 0,
332            nodes: Vec::new(),
333            top: HashMap::new(),
334            rng: 0x9E37_79B9_7F4A_7C15,
335            bucket_rows: 1,
336            buckets: Vec::new(),
337            dropped: BTreeSet::new(),
338            more_dropped: false,
339            malformed: 0,
340            malformed_lines: Vec::new(),
341            first_malformed: None,
342        }
343    }
344
345    /// Add a record.
346    pub fn push(&mut self, record: &Json) {
347        self.rows += 1;
348        let depth = self.options.depth.max(1);
349        // Depth first, in document order, with a stack rather than
350        // recursion: a record built in code may nest deeper than any parser
351        // allows.
352        let mut stack: Vec<(Option<usize>, Step<'_>, &Json, usize)> = match record {
353            Json::Object(map) => map
354                .iter()
355                .rev()
356                .map(|(key, value)| (None, Step::Key(key.as_str()), value, 1))
357                .collect(),
358            other => vec![(None, Step::Key("value"), other, 1)],
359        };
360        while let Some((parent, step, value, level)) = stack.pop() {
361            let Some(id) = self.node(parent, step) else {
362                continue;
363            };
364            let expand = level < depth;
365            self.observe(id, value, expand);
366            if !expand {
367                continue;
368            }
369            match value {
370                Json::Object(map) => stack.extend(
371                    map.iter()
372                        .rev()
373                        .map(|(key, value)| (Some(id), Step::Key(key.as_str()), value, level + 1)),
374                ),
375                Json::Array(items) => stack.extend(
376                    items
377                        .iter()
378                        .rev()
379                        .map(|item| (Some(id), Step::Item, item, level + 1)),
380                ),
381                _ => {}
382            }
383        }
384        self.end_record();
385    }
386
387    /// Count a line that could not be read as JSON (it is not a record):
388    /// its 1-based number and why.
389    pub fn skip_malformed(&mut self, line: usize, message: impl Into<String>) {
390        self.malformed += 1;
391        if self.malformed_lines.len() < MAX_MALFORMED_LINES {
392            self.malformed_lines.push(line);
393        }
394        if self.first_malformed.is_none() {
395            self.first_malformed = Some((line, message.into()));
396        }
397    }
398
399    /// The path `step` below `parent`, added when new and there is room;
400    /// `None` past the cap, after noting its name.
401    fn node(&mut self, parent: Option<usize>, step: Step<'_>) -> Option<usize> {
402        let found = match (parent, step) {
403            (None, Step::Key(key)) => self.top.get(key).copied(),
404            (Some(parent), Step::Key(key)) => self.nodes[parent].keys.get(key).copied(),
405            (Some(parent), Step::Item) => self.nodes[parent].item,
406            (None, Step::Item) => return None,
407        };
408        if found.is_some() {
409            return found;
410        }
411        let name = join(parent.map(|p| self.nodes[p].name.as_str()), step);
412        if self.nodes.len() >= self.options.max_paths.max(1) {
413            if !self.dropped.contains(&name) {
414                if self.dropped.len() < MAX_DROPPED {
415                    self.dropped.insert(name);
416                } else {
417                    self.more_dropped = true;
418                }
419            }
420            return None;
421        }
422        let id = self.nodes.len();
423        // Every record read so far lacked it.
424        for (rows, missing) in &mut self.buckets {
425            missing.push(*rows);
426        }
427        self.nodes.push(Node::new(name, parent));
428        match (parent, step) {
429            (None, Step::Key(key)) => {
430                self.top.insert(key.to_string(), id);
431            }
432            (Some(parent), Step::Key(key)) => {
433                self.nodes[parent].keys.insert(key.to_string(), id);
434            }
435            (Some(parent), Step::Item) => self.nodes[parent].item = Some(id),
436            (None, Step::Item) => {}
437        }
438        Some(id)
439    }
440
441    /// Count `value` at path `id`; an object or array that is `expand`ed has
442    /// no value of its own, its paths below holding them.
443    fn observe(&mut self, id: usize, value: &Json, expand: bool) {
444        let record = self.rows;
445        let node = &mut self.nodes[id];
446        node.values += 1;
447        node.types.add(value);
448        if node.last_present != record {
449            node.last_present = record;
450            node.present += 1;
451        }
452        if value.is_null() {
453            return;
454        }
455        node.last_value = record;
456        if expand && matches!(value, Json::Object(_) | Json::Array(_)) {
457            return;
458        }
459        // Algorithm R over the path's values, choosing the slot before
460        // building the cell (an object's JSON text costs its size).
461        node.seen += 1;
462        let sample = self.options.sample.max(1);
463        let (seen, kept) = (node.seen, node.reservoir.len());
464        if kept < sample {
465            self.nodes[id].reservoir.push(cell(value));
466            return;
467        }
468        let slot = self.next_random() % seen;
469        if let Some(kept) = self.nodes[id].reservoir.get_mut(slot as usize) {
470            *kept = cell(value);
471        }
472    }
473
474    /// The missing-value map for the record just walked: a path is missing
475    /// when the record lacks it or holds only `null` there.
476    fn end_record(&mut self) {
477        let record = self.rows;
478        if self
479            .buckets
480            .last()
481            .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
482        {
483            if self.buckets.len() >= self.options.buckets.max(1) {
484                self.merge_buckets();
485            }
486            if self
487                .buckets
488                .last()
489                .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
490            {
491                self.buckets.push((0, vec![0; self.nodes.len()]));
492            }
493        }
494        let bucket = self.buckets.last_mut().expect("a bucket was just added");
495        bucket.0 += 1;
496        for (missing, node) in bucket.1.iter_mut().zip(&self.nodes) {
497            if node.last_value != record {
498                *missing += 1;
499            }
500        }
501    }
502
503    /// As [`Profiler`](super::Profiler)'s: neighbouring buckets merged,
504    /// doubling the records per bucket.
505    fn merge_buckets(&mut self) {
506        let width = self.nodes.len();
507        self.buckets = self
508            .buckets
509            .chunks(2)
510            .map(|pair| {
511                let mut rows = 0;
512                let mut missing = vec![0; width];
513                for (r, m) in pair {
514                    rows += r;
515                    for (total, n) in missing.iter_mut().zip(m) {
516                        *total += n;
517                    }
518                }
519                (rows, missing)
520            })
521            .collect();
522        self.bucket_rows *= 2;
523    }
524
525    /// SplitMix64, as the row profiler's.
526    fn next_random(&mut self) -> u64 {
527        self.rng = self.rng.wrapping_add(0x9E37_79B9_7F4A_7C15);
528        let mut z = self.rng;
529        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
530        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
531        z ^ (z >> 31)
532    }
533
534    /// The paths in tree order: each after its parent, siblings in the
535    /// order first seen.
536    fn tree_order(&self) -> Vec<usize> {
537        let mut children: Vec<Vec<usize>> = vec![Vec::new(); self.nodes.len()];
538        let mut stack: Vec<usize> = Vec::new();
539        for (id, node) in self.nodes.iter().enumerate() {
540            match node.parent {
541                Some(parent) => children[parent].push(id),
542                None => stack.push(id),
543            }
544        }
545        stack.reverse();
546        let mut order = Vec::with_capacity(self.nodes.len());
547        while let Some(id) = stack.pop() {
548            order.push(id);
549            stack.extend(children[id].iter().rev());
550        }
551        order
552    }
553
554    /// The profile of every record pushed. Fails when
555    /// [`ProfileOptions::columns`] names a path that was not profiled.
556    pub fn finish(mut self) -> Result<Profile, DataError> {
557        let order = match &self.options.columns {
558            None => self.tree_order(),
559            Some(wanted) => wanted
560                .iter()
561                .map(|name| {
562                    self.nodes
563                        .iter()
564                        .position(|node| &node.name == name)
565                        .ok_or_else(|| self.unknown_path(name))
566                })
567                .collect::<Result<_, _>>()?,
568        };
569        let rows = self.rows;
570        let sample = self.options.sample.max(1);
571        let sampled = self.nodes.iter().any(|n| n.seen > n.reservoir.len() as u64);
572        let stats_options = StatsOptions {
573            quantiles: vec![0.25, 0.75],
574            top: self.options.top,
575        };
576        let mut columns = Vec::with_capacity(order.len());
577        for &id in &order {
578            let node = &mut self.nodes[id];
579            let mut values = Rows::new([node.name.clone()]);
580            for value in std::mem::take(&mut node.reservoir) {
581                values.push([value]);
582            }
583            // JSON has its own null: no text is a null token here.
584            let inference = Inferrer::new()
585                .null_tokens(std::iter::empty::<String>())
586                .infer(&values);
587            inference.apply(&mut values);
588            let stats = Stats::with_options(&values, &stats_options);
589            let s = &stats.columns()[0];
590            let types = node.types;
591            // A path that only ever held objects or arrays (walked into).
592            let data_type = if s.count == 0 && types.object + types.array > 0 {
593                if types.object >= types.array {
594                    "object"
595                } else {
596                    "array"
597                }
598            } else {
599                inference.columns()[0].data_type().name()
600            };
601            let mut column = describe(&values, 0, s, data_type, self.options.bins);
602            column.nulls = types.null;
603            column.null_rate = if node.values == 0 {
604                0.0
605            } else {
606                types.null as f64 / node.values as f64
607            };
608            column.present = Some(node.present);
609            column.absent = Some(rows - node.present);
610            column.values = Some(node.values);
611            column.types = Some(types);
612            column.mixed = Some(types.mixed());
613            columns.push(column);
614        }
615        let mut first = 1;
616        let buckets = self
617            .buckets
618            .iter()
619            .map(|(records, missing)| {
620                let bucket = MissingBucket {
621                    first,
622                    rows: *records,
623                    nulls: order.iter().map(|&id| missing[id]).collect(),
624                };
625                first += records;
626                bucket
627            })
628            .collect();
629        let mut notes = Vec::new();
630        let mixed: Vec<&str> = columns
631            .iter()
632            .filter(|c| c.mixed == Some(true))
633            .map(|c| c.name.as_str())
634            .collect();
635        if !mixed.is_empty() {
636            let count = mixed.len() as u64;
637            notes.push(format!(
638                "! marks {count} {} holding more than one JSON type besides null: {}",
639                plural(count, "path", "paths"),
640                listed(&mixed, count as usize)
641            ));
642        }
643        if !self.dropped.is_empty() {
644            let names: Vec<&str> = self.dropped.iter().map(String::as_str).collect();
645            let count = names.len() as u64;
646            let shown = if self.more_dropped {
647                format!("{}+", grouped(count))
648            } else {
649                grouped(count)
650            };
651            notes.push(format!(
652                "max_paths ({}) was reached: {shown} more {} not profiled: {}",
653                grouped(self.options.max_paths.max(1) as u64),
654                plural(count, "path is", "paths are"),
655                listed(
656                    &names,
657                    if self.more_dropped {
658                        usize::MAX
659                    } else {
660                        names.len()
661                    }
662                )
663            ));
664        }
665        if self.malformed > 0 {
666            let mut lines: Vec<String> =
667                self.malformed_lines.iter().map(usize::to_string).collect();
668            if self.malformed as usize > lines.len() {
669                lines.push("…".into());
670            }
671            let mut note = format!(
672                "{} {} not JSON and {} skipped: {} {}",
673                grouped(self.malformed),
674                plural(self.malformed, "line is", "lines are"),
675                plural(self.malformed, "was", "were"),
676                plural(self.malformed, "line", "lines"),
677                lines.join(", "),
678            );
679            if let Some((line, message)) = &self.first_malformed {
680                // serde_json's position is within the line: the line
681                // number says where.
682                let why = message.strip_prefix("not JSON: ").unwrap_or(message);
683                let why = why.split(" at line ").next().unwrap_or(why);
684                if self.malformed == 1 {
685                    note.push_str(&format!(" ({why})"));
686                } else {
687                    note.push_str(&format!(" (line {line}: {why})"));
688                }
689            }
690            notes.push(note);
691        }
692        Ok(Profile {
693            name: None,
694            rows,
695            sample,
696            columns,
697            missing: MissingMap {
698                bucket_rows: self.bucket_rows,
699                buckets,
700            },
701            notes,
702            paths: Some(PathSummary {
703                depth: self.options.depth.max(1),
704                max_paths: self.options.max_paths.max(1),
705                sampled,
706                capped: !self.dropped.is_empty(),
707                dropped: self.dropped.into_iter().collect(),
708                more_dropped: self.more_dropped,
709            }),
710            malformed: self.malformed,
711            malformed_lines: self.malformed_lines,
712            rules: Rules::default(),
713        })
714    }
715
716    /// The error for a path `columns` names that was not profiled.
717    fn unknown_path(&self, name: &str) -> DataError {
718        let names: Vec<&str> = self.nodes.iter().map(|n| n.name.as_str()).collect();
719        let mut message = format!(
720            "no path {:?}; the paths are {}",
721            rich_ext::sanitize::sanitize_single_line(name),
722            listed(&names, names.len())
723        );
724        if !self.dropped.is_empty() {
725            message.push_str(&format!(
726                " (max_paths {} was reached; a path seen later is not profiled)",
727                self.options.max_paths.max(1)
728            ));
729        }
730        DataError::new(message)
731    }
732}
733
734/// The first [`NOTE_NAMES`] of `names`, then `…` when `total` is more.
735fn listed(names: &[&str], total: usize) -> String {
736    let mut shown: Vec<String> = names
737        .iter()
738        .take(NOTE_NAMES)
739        .map(|n| rich_ext::sanitize::sanitize_single_line(n))
740        .collect();
741    if total > shown.len() {
742        shown.push("…".into());
743    }
744    shown.join(", ")
745}
746
747impl Profile {
748    /// A nested profile of JSON Lines from `reader`, streamed: a line that
749    /// is not JSON is counted and skipped ([`malformed`](Self::malformed)),
750    /// while a read error or a line longer than
751    /// [`jsonl::MAX_LINE`](crate::jsonl::MAX_LINE) fails. See
752    /// [`JsonProfiler`].
753    pub fn from_json_lines(
754        reader: impl BufRead,
755        options: ProfileOptions,
756    ) -> Result<Self, DataError> {
757        let mut profiler = JsonProfiler::new(options);
758        let mut lines = Lines::new(reader);
759        while let Some(record) = lines.next() {
760            match record {
761                Ok(record) => profiler.push(&record),
762                Err(error) if !lines.stopped() => {
763                    profiler.skip_malformed(error.line().unwrap_or(0), error.message())
764                }
765                Err(error) => return Err(error),
766            }
767        }
768        profiler.finish()
769    }
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use rich::Console;
776    use serde_json::json;
777
778    fn nested(text: &str, options: ProfileOptions) -> Profile {
779        Profile::from_json_lines(text.as_bytes(), options).unwrap()
780    }
781
782    fn names(profile: &Profile) -> Vec<&str> {
783        profile.columns().iter().map(|c| c.name.as_str()).collect()
784    }
785
786    #[test]
787    fn paths_follow_objects_and_arrays_in_tree_order() {
788        let p = nested(
789            "{\"id\": 1, \"items\": [{\"sku\": \"a\", \"qty\": 2}]}\n\
790             {\"id\": 2, \"user\": {\"name\": \"x\"}, \"items\": [{\"sku\": \"b\", \"note\": \"n\"}]}\n",
791            ProfileOptions::default(),
792        );
793        // `items[].note`, first seen in the second record, sits with its
794        // siblings rather than at the end.
795        assert_eq!(
796            names(&p),
797            [
798                "id",
799                "items",
800                "items[]",
801                "items[].sku",
802                "items[].qty",
803                "items[].note",
804                "user",
805                "user.name"
806            ]
807        );
808        assert_eq!(p.heading(), "2 records, 8 paths");
809        let sku = p.column("items[].sku").unwrap();
810        assert_eq!(
811            (sku.present, sku.absent, sku.values),
812            (Some(2), Some(0), Some(2))
813        );
814        let items = p.column("items").unwrap();
815        assert_eq!(items.data_type, "array");
816        assert_eq!(p.column("items[]").unwrap().data_type, "object");
817    }
818
819    #[test]
820    fn array_items_count_every_value_and_presence_counts_records() {
821        let p = nested(
822            "{\"tags\": [\"a\", \"b\", \"a\"]}\n{\"tags\": []}\n{}\n",
823            ProfileOptions::default(),
824        );
825        let tags = p.column("tags[]").unwrap();
826        assert_eq!(
827            (tags.values, tags.present, tags.absent),
828            (Some(3), Some(1), Some(2))
829        );
830        assert_eq!((tags.count, tags.distinct), (3, 2));
831        let tags = p.column("tags").unwrap();
832        assert_eq!((tags.present, tags.absent), (Some(2), Some(1)));
833    }
834
835    #[test]
836    fn absent_keys_are_counted_apart_from_null() {
837        let p = nested(
838            "{\"a\": 1, \"b\": null}\n{\"a\": null}\n{\"a\": 2, \"b\": \"x\"}\n{}\n",
839            ProfileOptions::default(),
840        );
841        let a = p.column("a").unwrap();
842        assert_eq!((a.present, a.absent, a.nulls), (Some(3), Some(1), 1));
843        assert_eq!(a.null_rate, 1.0 / 3.0);
844        let b = p.column("b").unwrap();
845        assert_eq!((b.present, b.absent, b.nulls), (Some(2), Some(2), 1));
846        assert_eq!(b.types.unwrap().mix(), "str 50% · null 50%");
847        assert_eq!(b.mixed, Some(false));
848        // The map: a record misses `b` when it lacks it or holds null.
849        let map = p.missing();
850        let b_missing: Vec<u64> = map.buckets.iter().map(|bucket| bucket.nulls[1]).collect();
851        assert_eq!(b_missing, [1, 1, 0, 1]);
852        let json = p.to_json();
853        assert_eq!(json["columns"][1]["absent"], 2);
854        assert_eq!(json["columns"][1]["types"], json!({"string": 1, "null": 1}));
855    }
856
857    #[test]
858    fn type_mixes_count_each_json_type() {
859        let p = nested(
860            "{\"v\": 1}\n{\"v\": \"1\"}\n{\"v\": true}\n{\"v\": [1]}\n{\"v\": {\"k\": 1}}\n\
861             {\"v\": null}\n{\"v\": 2.5}\n",
862            ProfileOptions::default(),
863        );
864        let v = p.column("v").unwrap();
865        let types = v.types.unwrap();
866        assert_eq!(
867            (
868                types.string,
869                types.number,
870                types.boolean,
871                types.object,
872                types.array,
873                types.null
874            ),
875            (1, 2, 1, 1, 1, 1)
876        );
877        assert_eq!(types.kinds(), 6);
878        assert!(types.mixed());
879        assert_eq!(
880            types.mix(),
881            "num 29% · str 14% · bool 14% · obj 14% · arr 14% · null 14%"
882        );
883        // A single type is its name alone.
884        let only = TypeCounts {
885            number: 3,
886            ..Default::default()
887        };
888        assert_eq!((only.mix(), only.mixed()), ("num".to_string(), false));
889        assert_eq!(TypeCounts::default().mix(), "");
890        // The table flags the mixed path.
891        let table = Console::builder()
892            .width(160)
893            .color_system(None)
894            .build()
895            .render_export(&p.to_table());
896        assert!(table.contains("! num 29%"), "{table}");
897        assert!(p.notes()[0].starts_with("! marks 1 path holding more than one JSON type"));
898    }
899
900    #[test]
901    fn depth_bounds_the_walk_and_unwalked_values_are_json_text() {
902        let text = "{\"a\": {\"b\": {\"c\": {\"d\": {\"e\": 1}}}}}\n";
903        let p = nested(text, ProfileOptions::default());
904        assert_eq!(names(&p), ["a", "a.b", "a.b.c", "a.b.c.d"]);
905        let deepest = p.column("a.b.c.d").unwrap();
906        assert_eq!(deepest.max.as_deref(), Some("{\"e\":1}"));
907        let shallow = nested(
908            text,
909            ProfileOptions {
910                depth: 1,
911                ..Default::default()
912            },
913        );
914        assert_eq!(names(&shallow), ["a"]);
915        assert_eq!(shallow.paths().unwrap().depth, 1);
916        // A record nested far deeper than any parser allows does not
917        // overflow the stack.
918        let mut deep = json!(1);
919        for _ in 0..100_000 {
920            deep = Json::Array(vec![deep]);
921        }
922        let mut profiler = JsonProfiler::new(ProfileOptions {
923            depth: usize::MAX,
924            max_paths: 10,
925            ..Default::default()
926        });
927        profiler.push(&deep);
928        let p = profiler.finish().unwrap();
929        assert_eq!(p.columns().len(), 10);
930        assert!(p.paths().unwrap().capped);
931        // Dropping the value iteratively too.
932        let mut value = deep;
933        while let Json::Array(mut items) = value {
934            value = items.pop().unwrap_or(Json::Null);
935        }
936    }
937
938    #[test]
939    fn the_path_cap_names_what_it_dropped_and_stays_bounded() {
940        let text: String = (0..1_000)
941            .map(|i| format!("{{\"id\": {i}, \"k{i:04}\": 1}}\n"))
942            .collect();
943        let p = nested(
944            &text,
945            ProfileOptions {
946                max_paths: 5,
947                ..Default::default()
948            },
949        );
950        assert_eq!(names(&p), ["id", "k0000", "k0001", "k0002", "k0003"]);
951        let paths = p.paths().unwrap();
952        assert!(paths.capped && paths.more_dropped);
953        assert_eq!(paths.dropped.len(), 100);
954        assert_eq!(paths.dropped[0], "k0004");
955        let note = p
956            .notes()
957            .iter()
958            .find(|n| n.starts_with("max_paths"))
959            .unwrap();
960        assert!(
961            note.starts_with(
962                "max_paths (5) was reached: 100+ more paths are not profiled: k0004, "
963            ),
964            "{note}"
965        );
966        assert!(note.ends_with(", …"), "{note}");
967        let table = Console::builder()
968            .width(160)
969            .color_system(None)
970            .build()
971            .render_export(&p.to_table());
972        assert!(
973            table.contains("100+ more paths not profiled (max_paths 5)"),
974            "{table}"
975        );
976        let json = p.to_json();
977        assert_eq!(json["paths"]["capped"], true);
978        assert_eq!(json["paths"]["max_paths"], 5);
979        // A later path in `columns` that the cap kept out says so.
980        let error = Profile::from_json_lines(
981            text.as_bytes(),
982            ProfileOptions {
983                max_paths: 5,
984                columns: Some(vec!["k0500".into()]),
985                ..Default::default()
986            },
987        )
988        .unwrap_err();
989        assert!(
990            error
991                .message()
992                .starts_with("no path \"k0500\"; the paths are id, k0000")
993        );
994        assert!(
995            error.message().contains("max_paths 5 was reached"),
996            "{error}"
997        );
998    }
999
1000    #[test]
1001    fn keys_that_look_like_paths_are_quoted() {
1002        let p = nested(
1003            "{\"a.b\": {\"c\": 1}, \"x[0]\": [true], \"\": 1, \" pad\": 2, \"q\\\"\": {\"k8s.io/n\": 3}}\n",
1004            ProfileOptions::default(),
1005        );
1006        assert_eq!(
1007            names(&p),
1008            [
1009                "[\"a.b\"]",
1010                "[\"a.b\"].c",
1011                "[\"x[0]\"]",
1012                "[\"x[0]\"][]",
1013                "[\"\"]",
1014                "[\" pad\"]",
1015                "[\"q\\\"\"]",
1016                "[\"q\\\"\"][\"k8s.io/n\"]"
1017            ]
1018        );
1019        // `columns` names paths as written.
1020        let p = nested(
1021            "{\"a.b\": {\"c\": 1}}\n",
1022            ProfileOptions {
1023                columns: Some(vec!["[\"a.b\"].c".into()]),
1024                ..Default::default()
1025            },
1026        );
1027        assert_eq!(names(&p), ["[\"a.b\"].c"]);
1028    }
1029
1030    #[test]
1031    fn malformed_lines_are_counted_and_never_fatal() {
1032        let mut text = String::from("{\"a\": 1}\n{\"a\": \nnot json\n\u{0}\u{1}\n[1, 2\n");
1033        for i in 0..20 {
1034            text.push_str(&format!("{{oops {i}\n"));
1035        }
1036        text.push_str("{\"a\": 2}\n\"just a string\"\n7\n");
1037        // Invalid UTF-8 too.
1038        let mut bytes = text.into_bytes();
1039        bytes.extend_from_slice(b"{\"a\": \"\xff\"}\n");
1040        let p = Profile::from_json_lines(&bytes[..], ProfileOptions::default()).unwrap();
1041        assert_eq!(p.rows(), 4);
1042        assert_eq!(p.malformed(), 25);
1043        assert_eq!(p.malformed_lines(), [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
1044        let note = p.notes().last().unwrap();
1045        assert!(
1046            note.starts_with("25 lines are not JSON and were skipped: lines 2, 3,"),
1047            "{note}"
1048        );
1049        assert!(
1050            note.ends_with(", 11, … (line 2: EOF while parsing a value)"),
1051            "{note}"
1052        );
1053        // Scalar records are the `value` path, as in a flat profile.
1054        assert_eq!(names(&p), ["a", "value"]);
1055        assert_eq!(
1056            p.column("value").unwrap().types.unwrap().mix(),
1057            "str 50% · num 50%"
1058        );
1059        let json = p.to_json();
1060        assert_eq!(json["malformed"], 25);
1061        assert_eq!(json["malformed_lines"][0], 2);
1062        // Nothing but malformed lines: an empty profile, still no panic.
1063        let p = nested("nope\n{\n", ProfileOptions::default());
1064        assert_eq!((p.rows(), p.columns().len(), p.malformed()), (0, 0, 2));
1065        let console = Console::builder().width(40).color_system(None).build();
1066        console.render_export(&p);
1067        // A read error still fails: what follows it is unknown.
1068        struct Broken(bool);
1069        impl std::io::Read for Broken {
1070            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1071                if std::mem::replace(&mut self.0, true) {
1072                    return Err(std::io::Error::other("disk on fire"));
1073                }
1074                let line = b"{\"a\": 1}\n";
1075                buf[..line.len()].copy_from_slice(line);
1076                Ok(line.len())
1077            }
1078        }
1079        let error = Profile::from_json_lines(
1080            std::io::BufReader::new(Broken(false)),
1081            ProfileOptions::default(),
1082        )
1083        .unwrap_err();
1084        assert_eq!((error.line(), error.message()), (Some(2), "disk on fire"));
1085    }
1086
1087    #[test]
1088    fn a_long_stream_is_sampled_per_path_with_exact_counts() {
1089        let options = ProfileOptions {
1090            sample: 100,
1091            buckets: 8,
1092            ..Default::default()
1093        };
1094        let mut profiler = JsonProfiler::new(options);
1095        for i in 0..20_000i64 {
1096            let record = if i % 4 == 0 {
1097                json!({"n": i, "xs": [i, i + 1]})
1098            } else {
1099                json!({"n": i, "s": null})
1100            };
1101            profiler.push(&record);
1102        }
1103        let p = profiler.finish().unwrap();
1104        assert!(p.sampled());
1105        assert_eq!(p.sample_size(), 100);
1106        assert_eq!(
1107            p.heading(),
1108            "20,000 records, 4 paths, sampled to 100 values a path"
1109        );
1110        let n = p.column("n").unwrap();
1111        assert_eq!((n.values, n.count), (Some(20_000), 100));
1112        let xs = p.column("xs[]").unwrap();
1113        assert_eq!(
1114            (xs.values, xs.present, xs.absent),
1115            (Some(10_000), Some(5_000), Some(15_000))
1116        );
1117        let s = p.column("s").unwrap();
1118        assert_eq!((s.nulls, s.null_rate, s.absent), (15_000, 1.0, Some(5_000)));
1119        // The map stays at its bucket limit and counts every record.
1120        assert!(p.missing().buckets.len() <= 8);
1121        let records: u64 = p.missing().buckets.iter().map(|b| b.rows).sum();
1122        assert_eq!(records, 20_000);
1123        let s_missing: u64 = p.missing().buckets.iter().map(|b| b.nulls[3]).sum();
1124        assert_eq!(s_missing, 20_000);
1125        // Reproducible.
1126        let again = {
1127            let mut profiler = JsonProfiler::new(ProfileOptions {
1128                sample: 100,
1129                buckets: 8,
1130                ..Default::default()
1131            });
1132            for i in 0..20_000i64 {
1133                profiler.push(&if i % 4 == 0 {
1134                    json!({"n": i, "xs": [i, i + 1]})
1135                } else {
1136                    json!({"n": i, "s": null})
1137                });
1138            }
1139            profiler.finish().unwrap()
1140        };
1141        assert_eq!(again, p);
1142    }
1143
1144    #[test]
1145    fn the_nested_model_round_trips_and_renders_narrow() {
1146        let p = nested(
1147            "{\"a\": {\"b\": [1, \"x\"]}, \"c\": null}\n{\"a\": 3}\nnope\n",
1148            ProfileOptions::default(),
1149        )
1150        .with_name("n.jsonl");
1151        let json = serde_json::to_string(&p).unwrap();
1152        let back: Profile = serde_json::from_str(&json).unwrap();
1153        assert_eq!(back, p);
1154        for width in [40, 50, 80] {
1155            let console = Console::builder().width(width).color_system(None).build();
1156            let segments = console.render(&p, None);
1157            for line in rich::Segment::split_lines(&segments) {
1158                let cells: usize = line.iter().map(rich::Segment::cell_length).sum();
1159                assert!(cells <= width, "{width}: {line:?}");
1160            }
1161        }
1162    }
1163}