1use std::cmp::Ordering;
12use std::sync::Arc;
13
14use arrow::array::{
15 Array, ArrayRef, AsArray, PrimitiveArray, RecordBatch, StringArray, StructArray, UInt64Array,
16 downcast_primitive_array, make_comparator, new_empty_array, new_null_array,
17};
18use arrow::compute::kernels::aggregate::{
19 max, max_string, max_string_view, min, min_string, min_string_view,
20};
21use arrow::compute::{SortOptions, cast, concat, take};
22use arrow::datatypes::{
23 ArrowPrimitiveType, DataType, Field, Fields, Float16Type, Float32Type, Float64Type, Schema,
24};
25use nd_arrow_array::extension::{VALUES_FIELD, is_nd_field};
26
27use crate::error::Result;
28
29pub const STATS_PREFIX: &str = "__stats__";
32
33pub const STATS_METADATA_KEY: &str = "lance_nd:stats";
35
36const STATS_ON: &str = "true";
38
39pub fn has_stats(schema: &Schema) -> bool {
41 schema
42 .metadata()
43 .get(STATS_METADATA_KEY)
44 .is_some_and(|value| value == STATS_ON)
45}
46
47pub fn stats_column(variable: &str) -> String {
49 format!("{STATS_PREFIX}:{variable}")
51}
52
53pub fn stats_variable(column: &str) -> Option<&str> {
55 column.strip_prefix(STATS_PREFIX)?.strip_prefix(':')
56}
57
58pub fn is_stats_name(name: &str) -> bool {
61 name.starts_with(STATS_PREFIX)
62}
63
64pub fn is_stats_type(data_type: &DataType) -> bool {
66 data_type.is_integer()
67 || data_type.is_floating()
68 || matches!(
69 data_type,
70 DataType::Utf8
71 | DataType::LargeUtf8
72 | DataType::Utf8View
73 | DataType::Timestamp(_, _)
74 | DataType::Date32
75 | DataType::Date64
76 | DataType::Time32(_)
77 | DataType::Time64(_)
78 | DataType::Duration(_)
79 )
80}
81
82pub fn add_stats(batch: &RecordBatch) -> Result<RecordBatch> {
88 let mut fields: Vec<Arc<Field>> = batch.schema().fields().iter().cloned().collect();
89 let mut columns = batch.columns().to_vec();
90 for (field, column) in batch.schema().fields().iter().zip(batch.columns()) {
91 if !is_nd_field(field) {
92 continue;
93 }
94 let nd = column.as_struct();
95 let values = nd
96 .column_by_name(VALUES_FIELD)
97 .expect("an nd column has values")
98 .as_list::<i32>();
99 let value_type = values.value_type();
100 if !is_stats_type(&value_type) {
101 continue;
102 }
103 let (min, max) = min_max(nd, values.offsets(), values.values().as_ref())?;
104 let bounds = Fields::from(vec![
105 Field::new("min", value_type.clone(), true),
106 Field::new("max", value_type, true),
107 ]);
108 let stats = StructArray::try_new(bounds.clone(), vec![min, max], nd.nulls().cloned())?;
109 let name = stats_column(field.name());
110 fields.push(Arc::new(Field::new(name, DataType::Struct(bounds), true)));
111 columns.push(Arc::new(stats));
112 }
113
114 let mut metadata = batch.schema().metadata().clone();
115 metadata.insert(STATS_METADATA_KEY.to_string(), STATS_ON.to_string());
116 let schema = Schema::new_with_metadata(fields, metadata);
117 Ok(RecordBatch::try_new(Arc::new(schema), columns)?)
118}
119
120fn min_max(nd: &StructArray, offsets: &[i32], values: &dyn Array) -> Result<(ArrayRef, ArrayRef)> {
123 let mut mins: Vec<ArrayRef> = Vec::with_capacity(nd.len());
124 let mut maxs: Vec<ArrayRef> = Vec::with_capacity(nd.len());
125 for row in 0..nd.len() {
126 let (min, max) = if nd.is_valid(row) {
127 let start = offsets[row] as usize;
128 let end = offsets[row + 1] as usize;
129 row_bounds(values.slice(start, end - start).as_ref())?
130 } else {
131 let null = new_null_array(values.data_type(), 1);
132 (null.clone(), null)
133 };
134 mins.push(min);
135 maxs.push(max);
136 }
137 let min = join(&mins, values.data_type())?;
138 let max = join(&maxs, values.data_type())?;
139 Ok((widen_zero(&min, -0.0), widen_zero(&max, 0.0)))
140}
141
142fn join(arrays: &[ArrayRef], data_type: &DataType) -> Result<ArrayRef> {
143 if arrays.is_empty() {
144 return Ok(new_empty_array(data_type));
145 }
146 let arrays: Vec<&dyn Array> = arrays.iter().map(|a| a.as_ref()).collect();
147 Ok(concat(&arrays)?)
148}
149
150fn row_bounds(values: &dyn Array) -> Result<(ArrayRef, ArrayRef)> {
152 let data_type = values.data_type().clone();
153 let fast: Option<(ArrayRef, ArrayRef)> = downcast_primitive_array!(
155 values => {
156 let (low, high) = (min(values), max(values));
157 (!is_nan(low) && !is_nan(high))
158 .then(|| (one(values, low, &data_type), one(values, high, &data_type)))
159 }
160 DataType::Utf8 => {
161 let values = values.as_string::<i32>();
162 Some(strings(min_string(values), max_string(values)))
163 }
164 DataType::LargeUtf8 => {
165 let values = values.as_string::<i64>();
166 Some(strings(min_string(values), max_string(values)))
167 }
168 DataType::Utf8View => {
169 let values = values.as_string_view();
170 Some(strings(min_string_view(values), max_string_view(values)))
171 }
172 _ => None
173 );
174 match fast {
175 Some(bounds) => {
176 let (low, high) = bounds;
177 Ok((cast(&low, &data_type)?, cast(&high, &data_type)?))
178 }
179 None => compared_bounds(values),
180 }
181}
182
183#[allow(clippy::eq_op)]
185fn is_nan<T: PartialEq>(value: Option<T>) -> bool {
186 value.is_some_and(|v| v != v)
187}
188
189fn one<T: ArrowPrimitiveType>(
191 _like: &PrimitiveArray<T>,
192 value: Option<T::Native>,
193 data_type: &DataType,
194) -> ArrayRef {
195 let array: PrimitiveArray<T> = [value].into_iter().collect();
196 Arc::new(array.with_data_type(data_type.clone()))
197}
198
199fn strings(low: Option<&str>, high: Option<&str>) -> (ArrayRef, ArrayRef) {
200 (
201 Arc::new(StringArray::from(vec![low])),
202 Arc::new(StringArray::from(vec![high])),
203 )
204}
205
206fn compared_bounds(values: &dyn Array) -> Result<(ArrayRef, ArrayRef)> {
208 let compare = make_comparator(values, values, SortOptions::default())?;
209 let mut low: Option<usize> = None;
210 let mut high: Option<usize> = None;
211 for i in (0..values.len()).filter(|&i| values.is_valid(i) && !is_nan_at(values, i)) {
212 if low.is_none_or(|l| compare(i, l) == Ordering::Less) {
213 low = Some(i);
214 }
215 if high.is_none_or(|h| compare(i, h) == Ordering::Greater) {
216 high = Some(i);
217 }
218 }
219 let low = take(
220 values,
221 &UInt64Array::from(vec![low.map(|i| i as u64)]),
222 None,
223 )?;
224 let high = take(
225 values,
226 &UInt64Array::from(vec![high.map(|i| i as u64)]),
227 None,
228 )?;
229 Ok((low, high))
230}
231
232fn widen_zero(bounds: &ArrayRef, zero: f64) -> ArrayRef {
235 match bounds.data_type() {
236 DataType::Float16 => {
237 let zero = <Float16Type as ArrowPrimitiveType>::Native::from_f64(zero);
238 Arc::new(
239 bounds
240 .as_primitive::<Float16Type>()
241 .unary::<_, Float16Type>(|v| if v.to_f64() == 0.0 { zero } else { v }),
242 )
243 }
244 DataType::Float32 => Arc::new(
245 bounds
246 .as_primitive::<Float32Type>()
247 .unary::<_, Float32Type>(|v| if v == 0.0 { zero as f32 } else { v }),
248 ),
249 DataType::Float64 => Arc::new(
250 bounds
251 .as_primitive::<Float64Type>()
252 .unary::<_, Float64Type>(|v| if v == 0.0 { zero } else { v }),
253 ),
254 _ => bounds.clone(),
255 }
256}
257
258fn is_nan_at(values: &dyn Array, i: usize) -> bool {
259 match values.data_type() {
260 DataType::Float16 => values.as_primitive::<Float16Type>().value(i).is_nan(),
261 DataType::Float32 => values.as_primitive::<Float32Type>().value(i).is_nan(),
262 DataType::Float64 => values.as_primitive::<Float64Type>().value(i).is_nan(),
263 _ => false,
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use arrow::array::{
270 Array, ArrayRef, AsArray, BooleanArray, Float64Array, Int32Array, StringArray,
271 TimestampNanosecondArray,
272 };
273 use arrow::datatypes::{Float64Type, Int32Type, TimeUnit};
274 use nd_arrow_array::{Dimensions, NdArrowArray, NdRecordBatch};
275
276 use super::*;
277 use crate::encode::encode_rows;
278
279 fn row(variables: Vec<(&str, ArrayRef)>) -> NdRecordBatch {
280 let fields: Vec<Field> = variables
281 .iter()
282 .map(|(name, array)| Field::new(*name, array.data_type().clone(), true))
283 .collect();
284 let arrays = variables
285 .into_iter()
286 .map(|(name, array)| {
287 let dims = Dimensions::try_new([(format!("{name}_dim"), array.len())]).unwrap();
288 NdArrowArray::try_new(array, dims).unwrap()
289 })
290 .collect();
291 NdRecordBatch::try_new_inferred(Arc::new(Schema::new(fields)), arrays).unwrap()
292 }
293
294 fn stats_of(rows: &[NdRecordBatch]) -> RecordBatch {
295 add_stats(&encode_rows(rows).unwrap()).unwrap()
296 }
297
298 fn child<'a>(batch: &'a RecordBatch, variable: &str) -> &'a StructArray {
299 batch
300 .column_by_name(&stats_column(variable))
301 .expect("a stats column")
302 .as_struct()
303 }
304
305 fn stats_names(schema: &Schema) -> Vec<String> {
307 schema
308 .fields()
309 .iter()
310 .map(|f| f.name().clone())
311 .filter(|name| is_stats_name(name))
312 .collect()
313 }
314
315 fn min_max(child: &StructArray) -> (&ArrayRef, &ArrayRef) {
316 (
317 child.column_by_name("min").unwrap(),
318 child.column_by_name("max").unwrap(),
319 )
320 }
321
322 #[test]
323 fn min_max_per_row() {
324 let batch = stats_of(&[
325 row(vec![
326 (
327 "lat",
328 Arc::new(Float64Array::from(vec![-90.0, -89.0, -88.0])),
329 ),
330 (
331 "sst",
332 Arc::new(Float64Array::from(vec![Some(5.0), None, Some(1.0)])),
333 ),
334 ]),
335 row(vec![
336 ("lat", Arc::new(Float64Array::from(vec![11.0, 10.0]))),
337 ("sst", Arc::new(Float64Array::from(vec![None, Some(7.0)]))),
338 ]),
339 ]);
340 let (min, max) = min_max(child(&batch, "lat"));
341 assert_eq!(min.as_primitive::<Float64Type>().values(), &[-90.0, 10.0]);
342 assert_eq!(max.as_primitive::<Float64Type>().values(), &[-88.0, 11.0]);
343 let (min, max) = min_max(child(&batch, "sst"));
344 assert_eq!(min.as_primitive::<Float64Type>().values(), &[1.0, 7.0]);
345 assert_eq!(max.as_primitive::<Float64Type>().values(), &[5.0, 7.0]);
346 assert!(has_stats(batch.schema().as_ref()));
347 }
348
349 #[test]
350 fn nan_and_all_null() {
351 let batch = stats_of(&[
352 row(vec![(
353 "v",
354 Arc::new(Float64Array::from(vec![f64::NAN, 2.0, f64::NAN])),
355 )]),
356 row(vec![(
357 "v",
358 Arc::new(Float64Array::from(vec![Some(f64::NAN), None])),
359 )]),
360 ]);
361 let stats = child(&batch, "v");
362 assert!(stats.is_valid(0) && stats.is_valid(1));
363 let (min, max) = min_max(stats);
364 assert_eq!(min.as_primitive::<Float64Type>().value(0), 2.0);
365 assert_eq!(max.as_primitive::<Float64Type>().value(0), 2.0);
366 assert!(min.is_null(1) && max.is_null(1));
367 }
368
369 #[test]
370 fn negative_nan_and_large_rows() {
371 let mut big: Vec<Option<f64>> = (0..10_000).map(|i| Some(i as f64)).collect();
372 big[17] = Some(-f64::NAN);
373 big[18] = None;
374 let batch = stats_of(&[
375 row(vec![(
376 "v",
377 Arc::new(Float64Array::from(vec![-f64::NAN, 1.0, 2.0])),
378 )]),
379 row(vec![("v", Arc::new(Float64Array::from(big)))]),
380 ]);
381 let (min, max) = min_max(child(&batch, "v"));
382 assert_eq!(min.as_primitive::<Float64Type>().values(), &[1.0, 0.0]);
383 assert_eq!(max.as_primitive::<Float64Type>().values(), &[2.0, 9999.0]);
384 }
385
386 #[test]
387 fn absent_variable_is_null_child() {
388 let batch = stats_of(&[
389 row(vec![(
390 "pressure",
391 Arc::new(Int32Array::from(vec![3, 1, 2])),
392 )]),
393 row(vec![("other", Arc::new(Int32Array::from(vec![0])))]),
394 ]);
395 let stats = child(&batch, "pressure");
396 assert!(stats.is_valid(0) && stats.is_null(1));
397 let (min, max) = min_max(stats);
398 assert_eq!(min.as_primitive::<Int32Type>().value(0), 1);
399 assert_eq!(max.as_primitive::<Int32Type>().value(0), 3);
400 }
401
402 #[test]
403 fn strings_and_timestamps() {
404 let times = TimestampNanosecondArray::from(vec![30, 10, 20]).with_timezone("UTC");
405 let batch = stats_of(&[row(vec![
406 ("name", Arc::new(StringArray::from(vec!["b", "a", "c"]))),
407 ("time", Arc::new(times)),
408 ])]);
409 let (min, max) = min_max(child(&batch, "name"));
410 assert_eq!(min.as_string::<i32>().value(0), "a");
411 assert_eq!(max.as_string::<i32>().value(0), "c");
412 let (min, max) = min_max(child(&batch, "time"));
413 assert_eq!(
414 min.data_type(),
415 &DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
416 );
417 assert_eq!(
418 min.as_primitive::<arrow::datatypes::TimestampNanosecondType>()
419 .value(0),
420 10
421 );
422 assert_eq!(
423 max.as_primitive::<arrow::datatypes::TimestampNanosecondType>()
424 .value(0),
425 30
426 );
427 }
428
429 #[test]
430 fn other_types_get_no_child() {
431 let batch = stats_of(&[row(vec![
432 ("flag", Arc::new(BooleanArray::from(vec![true, false]))),
433 ("v", Arc::new(Int32Array::from(vec![1]))),
434 ])]);
435 assert_eq!(stats_names(&batch.schema()), ["__stats__:v"]);
436 assert_eq!(stats_column("v"), format!("{STATS_PREFIX}:v"));
437
438 let only_flags = stats_of(&[row(vec![(
439 "flag",
440 Arc::new(BooleanArray::from(vec![true])),
441 )])]);
442 assert!(stats_names(&only_flags.schema()).is_empty());
443 assert!(has_stats(only_flags.schema().as_ref()));
444 }
445}