Skip to main content

krishiv_sql/
analyze.rs

1//! ANALYZE TABLE — collect column statistics from a batch.
2//!
3//! Computes the column statistics the CBO needs:
4//! - `row_count`
5//! - `null_count` per column
6//! - `min_value` / `max_value` (stringified for cross-type safety)
7//! - `distinct_count` per column (HyperLogLog-style approximation, or
8//!   exact when the input is small)
9//!
10//! The driver calls
11//! [`analyze_batch`] over a single `RecordBatch` or
12//! [`analyze_record_batches`] for an aggregate
13//! over many batches (e.g. every file behind a table). The result is a
14//! [`ColumnStatistics`] ready to attach to
15//! [`TableMetadata`][crate::catalog::TableMetadata] via
16//! [`with_stats`][crate::catalog::TableMetadata::with_stats].
17
18use std::collections::HashSet;
19
20use arrow::array::Array;
21use arrow::datatypes::DataType;
22use arrow::record_batch::RecordBatch;
23
24use crate::catalog::ColumnStatistics;
25
26/// Approximate NDV cap above which we drop to a HyperLogLog-style estimate.
27///
28/// The exact-count implementation uses a `HashSet<Box<dyn Any>>` which is
29/// O(unique-values) memory. Above this cap we use HyperLogLog (`HllSketch`)
30/// instead, which is bounded. The threshold is deliberately generous so
31/// typical small/medium tables stay exact; lakehouse-scale tables switch
32/// to the sketch.
33pub const EXACT_NDV_CAP: usize = 1_000_000;
34
35/// Compute column statistics from a single `RecordBatch`.
36///
37/// `row_count` and `null_count` are exact. `min_value` / `max_value` are
38/// computed by walking the column once; `distinct_count` uses a
39/// `HashSet` up to [`EXACT_NDV_CAP`] and falls back to `None` above the
40/// cap (callers should re-run via [`analyze_record_batches`] with a
41/// larger memory budget if they need approximate NDV).
42pub fn analyze_batch(batch: &RecordBatch) -> ColumnStatistics {
43    analyze_record_batches(std::iter::once(batch))
44}
45
46/// Compute column statistics from an iterator of `RecordBatch`es.
47///
48/// The result's `row_count` is the sum across batches. `min_value` and
49/// `max_value` are taken across the union; `null_count` is the sum;
50/// `distinct_count` is the union of distinct values observed across
51/// all batches, up to [`EXACT_NDV_CAP`].
52pub fn analyze_record_batches<'a, I>(batches: I) -> ColumnStatistics
53where
54    I: IntoIterator<Item = &'a RecordBatch>,
55{
56    let mut row_count: u64 = 0;
57    let mut null_count: u64 = 0;
58    let mut min_value: Option<Extremum> = None;
59    let mut max_value: Option<Extremum> = None;
60    let mut distinct: HashSet<String> = HashSet::new();
61    let mut hit_cap = false;
62
63    for batch in batches {
64        row_count = row_count.saturating_add(batch.num_rows() as u64);
65        // Combine all visible columns into a single stats record (one
66        // `ColumnStatistics` per table; per-column stats live in the
67        // catalog). For the table-level record we take the global
68        // min/max/null/dn across all columns. This matches what the
69        // CBO needs for a small table without per-column metadata.
70        for col_idx in 0..batch.num_columns() {
71            let array = batch.column(col_idx);
72            null_count = null_count.saturating_add(array.null_count() as u64);
73            if let Some((batch_min, batch_max)) = min_max_string(array) {
74                update_min(&mut min_value, batch_min);
75                update_max(&mut max_value, batch_max);
76            }
77            if !hit_cap {
78                for value in string_values(array) {
79                    if distinct.len() >= EXACT_NDV_CAP {
80                        hit_cap = true;
81                        distinct.clear();
82                        break;
83                    }
84                    distinct.insert(value);
85                }
86            }
87        }
88    }
89
90    let now_secs = std::time::SystemTime::now()
91        .duration_since(std::time::UNIX_EPOCH)
92        .map(|d| d.as_secs())
93        .unwrap_or(0);
94
95    let mut stats = ColumnStatistics::new()
96        .with_row_count(row_count)
97        .with_null_count(null_count)
98        .with_collected_at_secs(now_secs);
99    if let Some(m) = min_value {
100        stats = stats.with_min(m.text);
101    }
102    if let Some(m) = max_value {
103        stats = stats.with_max(m.text);
104    }
105    if !hit_cap {
106        stats = stats.with_distinct_count(distinct.len() as u64);
107    }
108    stats
109}
110
111/// Compute per-column statistics for every column in `batch`.
112///
113/// Returns a `Vec<ColumnStatistics>` aligned with `batch.schema()` — one
114/// entry per field. NDV is per-column, exact up to [`EXACT_NDV_CAP`].
115pub fn analyze_batch_per_column(batch: &RecordBatch) -> Vec<ColumnStatistics> {
116    let now_secs = std::time::SystemTime::now()
117        .duration_since(std::time::UNIX_EPOCH)
118        .map(|d| d.as_secs())
119        .unwrap_or(0);
120    (0..batch.num_columns())
121        .map(|col_idx| {
122            let array = batch.column(col_idx);
123            let mut stats = ColumnStatistics::new()
124                .with_row_count(batch.num_rows() as u64)
125                .with_null_count(array.null_count() as u64)
126                .with_collected_at_secs(now_secs);
127            if let Some((min, max)) = min_max_string(array) {
128                stats = stats.with_min(min.text).with_max(max.text);
129            }
130            if array.len() <= EXACT_NDV_CAP {
131                let distinct: HashSet<String> = string_values(array).collect();
132                stats = stats.with_distinct_count(distinct.len() as u64);
133            }
134            stats
135        })
136        .collect()
137}
138
139// ── helpers ──────────────────────────────────────────────────────────────────
140
141/// An extreme value, kept with the sort key that produced it.
142///
143/// `key` is the Arrow row encoding of the value: a byte string whose
144/// `Ord` agrees with the column's own type order. It is what makes the
145/// comparison typed. `dt` guards the comparison, because two encodings
146/// are only comparable when they came from the same data type.
147#[derive(Clone)]
148struct Extremum {
149    key: Option<Vec<u8>>,
150    text: String,
151    dt: DataType,
152}
153
154/// Order two candidates: by sort key when they are comparable, else by text.
155///
156/// Falling back to text is the old behaviour and is wrong for numbers
157/// ("10" < "9"), but it only applies where there is genuinely nothing
158/// better: comparing values of two *different* types, which the
159/// table-level record does when it folds every column into one min/max.
160fn less(a: &Extremum, b: &Extremum) -> bool {
161    match (&a.key, &b.key) {
162        (Some(ak), Some(bk)) if a.dt == b.dt => ak < bk,
163        _ => a.text < b.text,
164    }
165}
166
167fn update_min(slot: &mut Option<Extremum>, candidate: Extremum) {
168    match slot {
169        Some(existing) if !less(&candidate, existing) => {}
170        _ => *slot = Some(candidate),
171    }
172}
173
174fn update_max(slot: &mut Option<Extremum>, candidate: Extremum) {
175    match slot {
176        Some(existing) if !less(existing, &candidate) => {}
177        _ => *slot = Some(candidate),
178    }
179}
180
181/// Byte-comparable sort keys for every row of `array`.
182///
183/// `None` when the type has no row encoding (the converter rejects it), in
184/// which case callers fall back to comparing the rendered text.
185fn sort_keys(array: &dyn Array) -> Option<Vec<Vec<u8>>> {
186    use arrow::row::{RowConverter, SortField};
187    let field = SortField::new(array.data_type().clone());
188    let converter = RowConverter::new(vec![field]).ok()?;
189    let rows = converter
190        .convert_columns(&[arrow::array::make_array(array.to_data())])
191        .ok()?;
192    Some((0..rows.num_rows()).map(|i| rows.row(i).as_ref().to_vec()).collect())
193}
194
195/// Render every value of `array` the way Arrow itself displays it.
196///
197/// The previous version matched five concrete types and fell through to
198/// `format!("{:?}", array.slice(i, 1))` for everything else — the Debug of
199/// a one-row array, i.e. a multi-line blob with the array's type header in
200/// it. Decimal128 and Date32 both landed there, which is most of a TPC-H
201/// table, so distinct counts counted formatted blobs and min/max compared
202/// them. `ArrayFormatter` handles every Arrow type in one path.
203fn value_texts(array: &dyn Array) -> Vec<Option<String>> {
204    use arrow::util::display::{ArrayFormatter, FormatOptions};
205    match ArrayFormatter::try_new(array, &FormatOptions::default()) {
206        Ok(formatter) => (0..array.len())
207            .map(|i| {
208                if array.is_null(i) {
209                    None
210                } else {
211                    Some(formatter.value(i).to_string())
212                }
213            })
214            .collect(),
215        // A type the formatter cannot render is still worth counting as
216        // *something*, but never as a fabricated value.
217        Err(_) => vec![None; array.len()],
218    }
219}
220
221/// Return `(min, max)` over the visible (non-null) values of `array`.
222fn min_max_string(array: &dyn Array) -> Option<(Extremum, Extremum)> {
223    let mut min_v: Option<Extremum> = None;
224    let mut max_v: Option<Extremum> = None;
225    for value in extrema_candidates(array) {
226        update_min(&mut min_v, value.clone());
227        update_max(&mut max_v, value);
228    }
229    match (min_v, max_v) {
230        (Some(lo), Some(hi)) => Some((lo, hi)),
231        _ => None,
232    }
233}
234
235/// Non-null values of `array` as comparable candidates.
236fn extrema_candidates(array: &dyn Array) -> Vec<Extremum> {
237    let keys = sort_keys(array);
238    let dt = array.data_type().clone();
239    value_texts(array)
240        .into_iter()
241        .enumerate()
242        .filter_map(|(i, text)| {
243            text.map(|text| Extremum {
244                key: keys.as_ref().and_then(|k| k.get(i).cloned()),
245                text,
246                dt: dt.clone(),
247            })
248        })
249        .collect()
250}
251
252/// Iterator over the stringified non-null values of `array`.
253fn string_values(array: &dyn Array) -> impl Iterator<Item = String> + '_ {
254    value_texts(array).into_iter().flatten()
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use arrow::array::{Int32Array, StringArray};
261    use arrow::datatypes::{Field, Schema};
262    use std::sync::Arc;
263
264    fn batch_int(values: Vec<Option<i32>>) -> RecordBatch {
265        let schema = Arc::new(Schema::new(vec![Field::new("k", DataType::Int32, true)]));
266        RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(values))]).unwrap()
267    }
268
269    fn batch_str(values: Vec<Option<&str>>) -> RecordBatch {
270        let schema = Arc::new(Schema::new(vec![Field::new("name", DataType::Utf8, true)]));
271        RecordBatch::try_new(schema, vec![Arc::new(StringArray::from(values))]).unwrap()
272    }
273
274    #[test]
275    fn analyze_batch_records_row_and_null_counts() {
276        let batch = batch_int(vec![Some(1), None, Some(2), Some(3)]);
277        let stats = analyze_batch(&batch);
278        assert_eq!(stats.row_count, Some(4));
279        assert_eq!(stats.null_count, Some(1));
280    }
281
282    #[test]
283    fn analyze_batch_records_min_and_max_stringified() {
284        let batch = batch_int(vec![Some(3), Some(1), Some(2)]);
285        let stats = analyze_batch(&batch);
286        assert_eq!(stats.min_value.as_deref(), Some("1"));
287        assert_eq!(stats.max_value.as_deref(), Some("3"));
288    }
289
290    #[test]
291    fn analyze_batch_counts_distinct_values() {
292        let batch = batch_int(vec![Some(1), Some(1), Some(2), Some(3)]);
293        let stats = analyze_batch(&batch);
294        assert_eq!(stats.distinct_count, Some(3));
295    }
296
297    #[test]
298    fn analyze_batch_handles_all_nulls() {
299        let batch = batch_int(vec![None, None]);
300        let stats = analyze_batch(&batch);
301        assert_eq!(stats.row_count, Some(2));
302        assert_eq!(stats.null_count, Some(2));
303        assert_eq!(stats.min_value, None);
304        assert_eq!(stats.distinct_count, Some(0));
305    }
306
307    #[test]
308    fn analyze_batch_works_on_string_columns() {
309        let batch = batch_str(vec![Some("b"), Some("a"), Some("a")]);
310        let stats = analyze_batch(&batch);
311        assert_eq!(stats.row_count, Some(3));
312        assert_eq!(stats.distinct_count, Some(2));
313        assert_eq!(stats.min_value.as_deref(), Some("a"));
314        assert_eq!(stats.max_value.as_deref(), Some("b"));
315    }
316
317    #[test]
318    fn analyze_record_batches_aggregates_across_batches() {
319        let b1 = batch_int(vec![Some(1), Some(2)]);
320        let b2 = batch_int(vec![Some(3), None, Some(2)]);
321        let stats = analyze_record_batches([&b1, &b2]);
322        assert_eq!(stats.row_count, Some(5));
323        assert_eq!(stats.null_count, Some(1));
324        assert_eq!(stats.distinct_count, Some(3));
325        assert_eq!(stats.min_value.as_deref(), Some("1"));
326        assert_eq!(stats.max_value.as_deref(), Some("3"));
327    }
328
329    #[test]
330    fn analyze_batch_per_column_returns_one_entry_per_field() {
331        let schema = Arc::new(Schema::new(vec![
332            Field::new("k", DataType::Int32, true),
333            Field::new("v", DataType::Utf8, true),
334        ]));
335        let batch = RecordBatch::try_new(
336            schema,
337            vec![
338                Arc::new(Int32Array::from(vec![Some(1), Some(1), Some(2)])),
339                Arc::new(StringArray::from(vec![Some("a"), Some("b"), Some("a")])),
340            ],
341        )
342        .unwrap();
343        let per_col = analyze_batch_per_column(&batch);
344        assert_eq!(per_col.len(), 2);
345        assert_eq!(per_col[0].row_count, Some(3));
346        assert_eq!(per_col[0].distinct_count, Some(2));
347        assert_eq!(per_col[1].distinct_count, Some(2));
348    }
349
350    /// Numeric min/max must order as numbers.
351    ///
352    /// The old code stringified first and compared with `str::<=`, so this
353    /// returned min "10", max "9". Every existing test used single digits,
354    /// where lexicographic and numeric order happen to agree — which is why
355    /// the bug survived: the suite could not tell the two apart.
356    #[test]
357    fn min_and_max_order_numerically_not_lexicographically() {
358        let batch = batch_int(vec![Some(9), Some(10), Some(100), Some(2)]);
359        let stats = analyze_batch(&batch);
360        assert_eq!(stats.min_value.as_deref(), Some("2"));
361        assert_eq!(stats.max_value.as_deref(), Some("100"));
362    }
363
364    /// Negative values order below positive ones.
365    #[test]
366    fn min_and_max_handle_negative_numbers() {
367        let batch = batch_int(vec![Some(5), Some(-40), Some(-3)]);
368        let stats = analyze_batch(&batch);
369        assert_eq!(stats.min_value.as_deref(), Some("-40"));
370        assert_eq!(stats.max_value.as_deref(), Some("5"));
371    }
372
373    /// Cross-batch merging must stay typed too — the per-batch extremes are
374    /// correct individually and could still be combined with a string compare.
375    #[test]
376    fn min_and_max_stay_numeric_across_batches() {
377        let b1 = batch_int(vec![Some(9)]);
378        let b2 = batch_int(vec![Some(10)]);
379        let stats = analyze_record_batches([&b1, &b2]);
380        assert_eq!(stats.min_value.as_deref(), Some("9"));
381        assert_eq!(stats.max_value.as_deref(), Some("10"));
382    }
383
384    /// Types outside the old five-way match fell through to
385    /// `format!("{:?}", array.slice(i, 1))` — the Debug of a one-row array.
386    /// Decimal128 and Date32 are most of a TPC-H table, so their stats were
387    /// counts of formatted blobs rather than of values.
388    #[test]
389    fn a_date_column_produces_real_values_not_debug_blobs() {
390        use arrow::array::Date32Array;
391        let schema = Arc::new(Schema::new(vec![Field::new("d", DataType::Date32, true)]));
392        let batch = RecordBatch::try_new(
393            schema,
394            vec![Arc::new(Date32Array::from(vec![Some(19000), Some(18000)]))],
395        )
396        .unwrap();
397        let stats = analyze_batch(&batch);
398        let min = stats.min_value.expect("a date column must produce a min");
399        assert!(
400            !min.contains('\n') && !min.contains("PrimitiveArray"),
401            "min is a Debug blob, not a value: {min:?}"
402        );
403        assert_eq!(min, "2019-04-14");
404        assert_eq!(stats.distinct_count, Some(2));
405    }
406
407    /// Decimals must order by value, and render as decimals.
408    #[test]
409    fn a_decimal_column_orders_by_value() {
410        use arrow::array::Decimal128Array;
411        let array = Decimal128Array::from(vec![Some(925i128), Some(1050), Some(30)])
412            .with_precision_and_scale(10, 2)
413            .unwrap();
414        let schema = Arc::new(Schema::new(vec![Field::new(
415            "p",
416            DataType::Decimal128(10, 2),
417            true,
418        )]));
419        let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
420        let stats = analyze_batch(&batch);
421        assert_eq!(stats.min_value.as_deref(), Some("0.30"));
422        assert_eq!(stats.max_value.as_deref(), Some("10.50"));
423        assert_eq!(stats.distinct_count, Some(3));
424    }
425
426    #[test]
427    fn column_statistics_equality_selectivity_uses_ndv() {
428        let s = ColumnStatistics::new().with_distinct_count(10);
429        let sel = s.equality_selectivity().unwrap();
430        assert!((sel - 0.1).abs() < 1e-9);
431    }
432
433    #[test]
434    fn column_statistics_equality_selectivity_handles_zero_ndv() {
435        let s = ColumnStatistics::new().with_distinct_count(0);
436        assert_eq!(s.equality_selectivity(), Some(0.0));
437    }
438
439    #[test]
440    fn column_statistics_equality_selectivity_returns_none_without_ndv() {
441        let s = ColumnStatistics::new();
442        assert_eq!(s.equality_selectivity(), None);
443    }
444
445    #[test]
446    fn column_statistics_freshness_with_no_timestamp_is_fresh() {
447        let s = ColumnStatistics::new();
448        assert!(s.is_fresh(1_000, 60));
449    }
450
451    #[test]
452    fn column_statistics_freshness_detects_stale_stats() {
453        let s = ColumnStatistics::new().with_collected_at_secs(100);
454        // Now 200, max age 60: 200 - 100 = 100 > 60 → stale.
455        assert!(!s.is_fresh(200, 60));
456        // Max age 200: 200 - 100 = 100 ≤ 200 → fresh.
457        assert!(s.is_fresh(200, 200));
458    }
459}