From 7a151c42dc48984e1fd2e4dc30ca26e1651d1e8e Mon Sep 17 00:00:00 2001 From: shaoyijie Date: Mon, 1 Jun 2026 14:36:56 +0800 Subject: [PATCH 1/3] fix: write valid empty BinaryRow bytes for empty table stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty `BinaryTableStats` previously stored `Vec::new()` for `min_values` / `max_values`. Java readers parse those fields via `SerializationUtils.deserializeBinaryRow`, which requires at minimum a 4-byte big-endian arity prefix and throws `BufferUnderflowException` on zero-length input. The most visible casualty is a non-partitioned table written via paimon-rust: any Spark/Flink read of its manifest list crashes inside `SimpleStats.fromRow` because `_PARTITION_STATS._MIN_VALUES` is empty bytes. Introduce `BinaryTableStats::empty()` that returns stats backed by `EMPTY_SERIALIZED_ROW` (the arity=0 BinaryRow, 12 bytes) and route every existing call site through it: the production fix in `compute_partition_stats`, the Avro decode fallbacks for missing `_PARTITION_STATS` / `key_stats` / `value_stats`, and the test fixtures that copied the old pattern. Add regression tests that round-trip the empty stats through `BinaryRow::from_serialized_bytes` — the same protocol the Java reader uses — so future copies of `vec![]` are caught locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/spec/avro/manifest_entry_decode.rs | 8 +-- .../spec/avro/manifest_file_meta_decode.rs | 2 +- crates/paimon/src/spec/manifest.rs | 2 +- crates/paimon/src/spec/stats.rs | 52 ++++++++++++++++++- .../paimon/src/table/data_evolution_writer.rs | 2 +- crates/paimon/src/table/referenced_files.rs | 4 +- crates/paimon/src/table/table_commit.rs | 45 ++++++++++++++-- 7 files changed, 102 insertions(+), 13 deletions(-) diff --git a/crates/paimon/src/spec/avro/manifest_entry_decode.rs b/crates/paimon/src/spec/avro/manifest_entry_decode.rs index 1d3ffcfa..df251bd3 100644 --- a/crates/paimon/src/spec/avro/manifest_entry_decode.rs +++ b/crates/paimon/src/spec/avro/manifest_entry_decode.rs @@ -270,8 +270,8 @@ fn decode_data_file_meta( row_count: row_count.unwrap_or(0), min_key: min_key.unwrap_or_default(), max_key: max_key.unwrap_or_default(), - key_stats: key_stats.unwrap_or_else(|| BinaryTableStats::new(vec![], vec![], vec![])), - value_stats: value_stats.unwrap_or_else(|| BinaryTableStats::new(vec![], vec![], vec![])), + key_stats: key_stats.unwrap_or_else(BinaryTableStats::empty), + value_stats: value_stats.unwrap_or_else(BinaryTableStats::empty), min_sequence_number: min_sequence_number.unwrap_or(0), max_sequence_number: max_sequence_number.unwrap_or(0), schema_id: schema_id.unwrap_or(0), @@ -391,8 +391,8 @@ fn default_data_file_meta() -> DataFileMeta { row_count: 0, min_key: vec![], max_key: vec![], - key_stats: BinaryTableStats::new(vec![], vec![], vec![]), - value_stats: BinaryTableStats::new(vec![], vec![], vec![]), + key_stats: BinaryTableStats::empty(), + value_stats: BinaryTableStats::empty(), min_sequence_number: 0, max_sequence_number: 0, schema_id: 0, diff --git a/crates/paimon/src/spec/avro/manifest_file_meta_decode.rs b/crates/paimon/src/spec/avro/manifest_file_meta_decode.rs index 67d4f2ee..20ac8cb8 100644 --- a/crates/paimon/src/spec/avro/manifest_file_meta_decode.rs +++ b/crates/paimon/src/spec/avro/manifest_file_meta_decode.rs @@ -64,7 +64,7 @@ impl AvroRecordDecode for ManifestFileMeta { file_size.unwrap_or(0), num_added_files.unwrap_or(0), num_deleted_files.unwrap_or(0), - partition_stats.unwrap_or_else(|| BinaryTableStats::new(vec![], vec![], vec![])), + partition_stats.unwrap_or_else(BinaryTableStats::empty), schema_id.unwrap_or(0), min_row_id, max_row_id, diff --git a/crates/paimon/src/spec/manifest.rs b/crates/paimon/src/spec/manifest.rs index cd16133b..bebd09e7 100644 --- a/crates/paimon/src/spec/manifest.rs +++ b/crates/paimon/src/spec/manifest.rs @@ -125,7 +125,7 @@ mod tests { use crate::spec::ManifestEntry; fn entry(kind: FileKind, file_name: &str, level: i32) -> ManifestEntry { - let stats = BinaryTableStats::new(vec![], vec![], vec![]); + let stats = BinaryTableStats::empty(); let file = DataFileMeta { file_name: file_name.to_string(), file_size: 100, diff --git a/crates/paimon/src/spec/stats.rs b/crates/paimon/src/spec/stats.rs index 2c389a39..4ae9b243 100644 --- a/crates/paimon/src/spec/stats.rs +++ b/crates/paimon/src/spec/stats.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::fmt::{Display, Formatter}; -use super::{extract_datum_from_arrow, BinaryRowBuilder, DataType, Datum}; +use super::{extract_datum_from_arrow, BinaryRowBuilder, DataType, Datum, EMPTY_SERIALIZED_ROW}; use arrow_array::RecordBatch; /// Deserialize `_NULL_COUNTS` which in Avro is `["null", {"type":"array","items":["null","long"]}]`. @@ -94,6 +94,21 @@ impl BinaryTableStats { null_counts, } } + + /// Stats with empty (arity=0) BinaryRow bytes for min/max and no null counts. + /// + /// Use this whenever there are no columns to collect stats for (e.g. a non-partitioned + /// table's `partition_stats`, or a writer producing no key/value stats columns). Writing + /// `Vec::new()` here breaks the Java reader: `SerializationUtils.deserializeBinaryRow` + /// requires at least the 4-byte BE arity prefix and throws `BufferUnderflowException` on + /// zero-length input. + pub fn empty() -> BinaryTableStats { + Self { + min_values: EMPTY_SERIALIZED_ROW.clone(), + max_values: EMPTY_SERIALIZED_ROW.clone(), + null_counts: Vec::new(), + } + } } impl Display for BinaryTableStats { @@ -154,3 +169,38 @@ pub fn compute_column_stats( null_counts, )) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec::BinaryRow; + + /// Empty stats must produce min/max bytes that the Java side's + /// `SerializationUtils.deserializeBinaryRow` accepts: at minimum a 4-byte BE + /// arity prefix. A bare `Vec::new()` would trigger `BufferUnderflowException` + /// when Spark/Flink read manifests written for a non-partitioned table. + #[test] + fn empty_stats_carries_arity_prefix_parseable_by_reader() { + let stats = BinaryTableStats::empty(); + assert!( + stats.min_values().len() >= 4, + "min_values must contain at least the 4-byte arity prefix" + ); + assert!( + stats.max_values().len() >= 4, + "max_values must contain at least the 4-byte arity prefix" + ); + assert!( + stats.null_counts().is_empty(), + "null_counts stays empty so the Java reader short-circuits to EMPTY_STATS" + ); + + // Round-trip through the same parser the Java reader uses (4-byte BE arity). + let min_row = BinaryRow::from_serialized_bytes(stats.min_values()) + .expect("min_values must decode as a BinaryRow"); + let max_row = BinaryRow::from_serialized_bytes(stats.max_values()) + .expect("max_values must decode as a BinaryRow"); + assert_eq!(min_row.arity(), 0); + assert_eq!(max_row.arity(), 0); + } +} diff --git a/crates/paimon/src/table/data_evolution_writer.rs b/crates/paimon/src/table/data_evolution_writer.rs index fe9e2348..f63ac24e 100644 --- a/crates/paimon/src/table/data_evolution_writer.rs +++ b/crates/paimon/src/table/data_evolution_writer.rs @@ -670,7 +670,7 @@ mod tests { write_cols: Option>, ) -> DataFileMeta { use crate::spec::stats::BinaryTableStats; - let empty_stats = BinaryTableStats::new(vec![], vec![], vec![]); + let empty_stats = BinaryTableStats::empty(); DataFileMeta { file_name: file_name.to_string(), file_size: 0, diff --git a/crates/paimon/src/table/referenced_files.rs b/crates/paimon/src/table/referenced_files.rs index a170b3c8..755b00f0 100644 --- a/crates/paimon/src/table/referenced_files.rs +++ b/crates/paimon/src/table/referenced_files.rs @@ -1063,8 +1063,8 @@ mod tests { row_count: 100, min_key: vec![], max_key: vec![], - key_stats: BinaryTableStats::new(vec![], vec![], vec![]), - value_stats: BinaryTableStats::new(vec![], vec![], vec![]), + key_stats: BinaryTableStats::empty(), + value_stats: BinaryTableStats::empty(), min_sequence_number: 0, max_sequence_number: 0, schema_id: 0, diff --git a/crates/paimon/src/table/table_commit.rs b/crates/paimon/src/table/table_commit.rs index 664b6542..79342ac7 100644 --- a/crates/paimon/src/table/table_commit.rs +++ b/crates/paimon/src/table/table_commit.rs @@ -918,7 +918,7 @@ impl TableCommit { let num_fields = partition_fields.len(); if num_fields == 0 || entries.is_empty() { - return Ok(BinaryTableStats::new(vec![], vec![], vec![])); + return Ok(BinaryTableStats::empty()); } let data_types: Vec<_> = partition_fields @@ -1220,8 +1220,8 @@ mod tests { row_count, min_key: vec![], max_key: vec![], - key_stats: BinaryTableStats::new(vec![], vec![], vec![]), - value_stats: BinaryTableStats::new(vec![], vec![], vec![]), + key_stats: BinaryTableStats::empty(), + value_stats: BinaryTableStats::empty(), min_sequence_number: 0, max_sequence_number: 0, schema_id: 0, @@ -1822,4 +1822,43 @@ mod tests { "Expected 'Delete conflict' error, got: {err_msg}" ); } + + /// Regression: a non-partitioned table (e.g. `CREATE TABLE test_pk (... PRIMARY KEY ...)`) + /// must still emit `_PARTITION_STATS._MIN_VALUES`/`_MAX_VALUES` carrying the 4-byte BE + /// arity prefix; otherwise Java readers like Spark/Flink hit + /// `BufferUnderflowException` inside `SerializationUtils.deserializeBinaryRow`. + #[test] + fn compute_partition_stats_no_partition_fields_returns_decodable_empty() { + let file_io = test_file_io(); + let commit = setup_commit(&file_io, "memory:/test_no_partition_stats"); + + let entry = ManifestEntry::new( + FileKind::Add, + vec![], + 0, + 1, + test_data_file("data-0.parquet", 1), + 2, + ); + + let stats = commit.compute_partition_stats(&[entry]).unwrap(); + BinaryRow::from_serialized_bytes(stats.min_values()) + .expect("min_values must decode via the same protocol as Java's deserializeBinaryRow"); + BinaryRow::from_serialized_bytes(stats.max_values()) + .expect("max_values must decode via the same protocol as Java's deserializeBinaryRow"); + assert!(stats.null_counts().is_empty()); + } + + /// Regression: when there are no entries at all, the empty stats we return must also + /// satisfy the protocol — same Java reader path runs on it. + #[test] + fn compute_partition_stats_empty_entries_returns_decodable_empty() { + let file_io = test_file_io(); + let commit = setup_partitioned_commit(&file_io, "memory:/test_no_entries_stats"); + + let stats = commit.compute_partition_stats(&[]).unwrap(); + BinaryRow::from_serialized_bytes(stats.min_values()).unwrap(); + BinaryRow::from_serialized_bytes(stats.max_values()).unwrap(); + assert!(stats.null_counts().is_empty()); + } } From b23f8706d9995a3fdc454300daf9d4c6c2b11c46 Mon Sep 17 00:00:00 2001 From: shaoyijie Date: Tue, 2 Jun 2026 10:35:06 +0800 Subject: [PATCH 2/3] fix: write decodable min/max bytes for all-null partition stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit covered the empty-stats path (no partition columns or no entries), but `compute_partition_stats` could still emit zero-length `_MIN_VALUES` / `_MAX_VALUES` for a partitioned table whose committed entries carry NULL partition values. After the per-entry loop, when every collected min/max stays `None`, the path called `spec::datums_to_binary_row(&all_none)` which intentionally collapses to `vec![]` — a valid "no partition columns" sentinel used by `partitions_to_bytes`, but a protocol violation here. The accompanying `null_counts` is non-empty (incremented per null observed), so Java's `SimpleStats.fromRow` cannot short-circuit to `EMPTY_STATS` and crashes inside `SerializationUtils.deserializeBinaryRow` on the first `buffer.getInt()` — the same `BufferUnderflowException` the parent fix targets. Stop routing this through `datums_to_binary_row`. Add a tiny `build_partition_stats_row` helper that always produces a protocol-valid serialized `BinaryRow` with the partition schema's arity, setting the null bit for every `None` datum (mirroring Java's `SimpleStatsConverter.toBinaryAllMode` with a `GenericRow(num_fields)`). `datums_to_binary_row` is left alone because `partitions_to_bytes` relies on its `vec![]` sentinel. Add a regression test `compute_partition_stats_all_null_partition_values_returns_decodable_bytes` that hand-builds an entry with a valid arity-1 BinaryRow whose only partition column is NULL, runs `compute_partition_stats`, and asserts the min/max bytes decode back to an arity-1 BinaryRow with the null bit set and `null_counts == [Some(1)]`. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/paimon/src/table/table_commit.rs | 97 +++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/crates/paimon/src/table/table_commit.rs b/crates/paimon/src/table/table_commit.rs index 79342ac7..ae86ceaf 100644 --- a/crates/paimon/src/table/table_commit.rs +++ b/crates/paimon/src/table/table_commit.rs @@ -24,9 +24,9 @@ use crate::io::FileIO; use crate::spec::stats::BinaryTableStats; use crate::spec::FileKind; use crate::spec::{ - datums_to_binary_row, extract_datum, BinaryRow, CommitKind, CoreOptions, DataType, Datum, - IndexManifest, IndexManifestEntry, Manifest, ManifestEntry, ManifestFileMeta, ManifestList, - PartitionStatistics, Snapshot, + datums_to_binary_row, extract_datum, BinaryRow, BinaryRowBuilder, CommitKind, CoreOptions, + DataType, Datum, IndexManifest, IndexManifestEntry, Manifest, ManifestEntry, ManifestFileMeta, + ManifestList, PartitionStatistics, Snapshot, }; use crate::table::commit_message::CommitMessage; use crate::table::partition_filter::PartitionFilter; @@ -956,11 +956,15 @@ impl TableCommit { } } - let min_datums: Vec<_> = mins.iter().zip(data_types.iter()).collect(); - let max_datums: Vec<_> = maxs.iter().zip(data_types.iter()).collect(); - - let min_bytes = datums_to_binary_row(&min_datums); - let max_bytes = datums_to_binary_row(&max_datums); + // Always emit a serialized BinaryRow whose arity matches the partition schema — + // even when every collected min/max is None (the partition has only null values). + // `datums_to_binary_row` collapses that case to `vec![]` for use as a "no partition + // columns" sentinel elsewhere; here that sentinel would make Java's reader hit + // `BufferUnderflowException` inside `SerializationUtils.deserializeBinaryRow` + // because `null_counts` is non-empty and so `SimpleStats.fromRow` no longer + // short-circuits to `EMPTY_STATS`. + let min_bytes = build_partition_stats_row(&mins, &data_types); + let max_bytes = build_partition_stats_row(&maxs, &data_types); let null_counts = null_counts.into_iter().map(Some).collect(); Ok(BinaryTableStats::new(min_bytes, max_bytes, null_counts)) @@ -1113,6 +1117,27 @@ impl TableCommit { } } +/// Build a serialized BinaryRow for partition min/max stats with `datums.len()` arity, +/// setting the null bit for every position whose datum is `None`. Mirrors what Java does +/// in `SimpleStatsConverter.toBinaryAllMode` for non-zero arity — always a decodable row, +/// never an empty `Vec`. +/// +/// We deliberately do not reuse `spec::datums_to_binary_row` here: that helper collapses an +/// all-None input to `vec![]`, which is a valid "no partition columns" sentinel for +/// `partitions_to_bytes` but a protocol violation when stuffed into `BinaryTableStats` +/// alongside a non-empty `null_counts` (Java's `SimpleStats.fromRow` cannot short-circuit +/// to `EMPTY_STATS` and crashes inside `SerializationUtils.deserializeBinaryRow`). +fn build_partition_stats_row(datums: &[Option], data_types: &[DataType]) -> Vec { + let mut builder = BinaryRowBuilder::new(datums.len() as i32); + for (pos, (datum_opt, data_type)) in datums.iter().zip(data_types.iter()).enumerate() { + match datum_opt { + Some(d) => builder.write_datum(pos, d, data_type), + None => builder.set_null_at(pos), + } + } + builder.build_serialized() +} + /// Plan for resolving commit entries. enum CommitEntriesPlan { /// Caller-provided entries. May contain `FileKind::Delete` entries from CoW @@ -1861,4 +1886,60 @@ mod tests { BinaryRow::from_serialized_bytes(stats.max_values()).unwrap(); assert!(stats.null_counts().is_empty()); } + + /// Regression: a partitioned table whose committed entries carry an all-null + /// partition row (every partition column is NULL) must still emit decodable + /// min/max BinaryRow bytes. The previous implementation routed through + /// `datums_to_binary_row`, which returns `Vec::new()` when every collected datum + /// is `None`; combined with a non-empty `null_counts`, `SimpleStats.fromRow` + /// cannot short-circuit to `EMPTY_STATS` and Spark / Flink hit + /// `BufferUnderflowException` on `getBinary(0).getInt()`. + #[test] + fn compute_partition_stats_all_null_partition_values_returns_decodable_bytes() { + let file_io = test_file_io(); + let commit = setup_partitioned_commit(&file_io, "memory:/test_all_null_partition_stats"); + + // A valid arity-1 BinaryRow with the lone partition column set to NULL. + let mut builder = BinaryRowBuilder::new(1); + builder.set_null_at(0); + let null_partition = builder.build_serialized(); + + let entry = ManifestEntry::new( + FileKind::Add, + null_partition, + 0, + 1, + test_data_file("data-null-pt.parquet", 1), + 2, + ); + + let stats = commit.compute_partition_stats(&[entry]).unwrap(); + let min_row = BinaryRow::from_serialized_bytes(stats.min_values()) + .expect("min_values must decode as a BinaryRow for the Java reader"); + let max_row = BinaryRow::from_serialized_bytes(stats.max_values()) + .expect("max_values must decode as a BinaryRow for the Java reader"); + assert_eq!( + min_row.arity(), + 1, + "min row arity must match the partition schema" + ); + assert_eq!( + max_row.arity(), + 1, + "max row arity must match the partition schema" + ); + assert!( + min_row.is_null_at(0), + "min value must be NULL since the only input was NULL" + ); + assert!( + max_row.is_null_at(0), + "max value must be NULL since the only input was NULL" + ); + assert_eq!( + stats.null_counts(), + &vec![Some(1)], + "the single all-null entry should be counted", + ); + } } From dc62a5e64c6d8d8979a3a767e274d73c02403da1 Mon Sep 17 00:00:00 2001 From: shaoyijie Date: Tue, 2 Jun 2026 10:49:00 +0800 Subject: [PATCH 3/3] chore: trim verbose comments in all-null stats fix Per review feedback the prior commit's inline rationale was too long. The WHY lives in the commit message and the regression test name; keep only a one-line doc on the helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/paimon/src/table/table_commit.rs | 64 +++++-------------------- 1 file changed, 12 insertions(+), 52 deletions(-) diff --git a/crates/paimon/src/table/table_commit.rs b/crates/paimon/src/table/table_commit.rs index ae86ceaf..1102712d 100644 --- a/crates/paimon/src/table/table_commit.rs +++ b/crates/paimon/src/table/table_commit.rs @@ -956,13 +956,6 @@ impl TableCommit { } } - // Always emit a serialized BinaryRow whose arity matches the partition schema — - // even when every collected min/max is None (the partition has only null values). - // `datums_to_binary_row` collapses that case to `vec![]` for use as a "no partition - // columns" sentinel elsewhere; here that sentinel would make Java's reader hit - // `BufferUnderflowException` inside `SerializationUtils.deserializeBinaryRow` - // because `null_counts` is non-empty and so `SimpleStats.fromRow` no longer - // short-circuits to `EMPTY_STATS`. let min_bytes = build_partition_stats_row(&mins, &data_types); let max_bytes = build_partition_stats_row(&maxs, &data_types); let null_counts = null_counts.into_iter().map(Some).collect(); @@ -1117,16 +1110,9 @@ impl TableCommit { } } -/// Build a serialized BinaryRow for partition min/max stats with `datums.len()` arity, -/// setting the null bit for every position whose datum is `None`. Mirrors what Java does -/// in `SimpleStatsConverter.toBinaryAllMode` for non-zero arity — always a decodable row, -/// never an empty `Vec`. -/// -/// We deliberately do not reuse `spec::datums_to_binary_row` here: that helper collapses an -/// all-None input to `vec![]`, which is a valid "no partition columns" sentinel for -/// `partitions_to_bytes` but a protocol violation when stuffed into `BinaryTableStats` -/// alongside a non-empty `null_counts` (Java's `SimpleStats.fromRow` cannot short-circuit -/// to `EMPTY_STATS` and crashes inside `SerializationUtils.deserializeBinaryRow`). +/// Serialized BinaryRow for partition stats; unlike `datums_to_binary_row`, returns a +/// valid arity-N row even when every datum is `None` (the all-null case must still +/// decode on the Java side). fn build_partition_stats_row(datums: &[Option], data_types: &[DataType]) -> Vec { let mut builder = BinaryRowBuilder::new(datums.len() as i32); for (pos, (datum_opt, data_type)) in datums.iter().zip(data_types.iter()).enumerate() { @@ -1887,19 +1873,13 @@ mod tests { assert!(stats.null_counts().is_empty()); } - /// Regression: a partitioned table whose committed entries carry an all-null - /// partition row (every partition column is NULL) must still emit decodable - /// min/max BinaryRow bytes. The previous implementation routed through - /// `datums_to_binary_row`, which returns `Vec::new()` when every collected datum - /// is `None`; combined with a non-empty `null_counts`, `SimpleStats.fromRow` - /// cannot short-circuit to `EMPTY_STATS` and Spark / Flink hit - /// `BufferUnderflowException` on `getBinary(0).getInt()`. + /// Regression: partitioned table with an all-null partition row must still emit + /// decodable min/max bytes (otherwise Java hits `BufferUnderflowException`). #[test] fn compute_partition_stats_all_null_partition_values_returns_decodable_bytes() { let file_io = test_file_io(); let commit = setup_partitioned_commit(&file_io, "memory:/test_all_null_partition_stats"); - // A valid arity-1 BinaryRow with the lone partition column set to NULL. let mut builder = BinaryRowBuilder::new(1); builder.set_null_at(0); let null_partition = builder.build_serialized(); @@ -1914,32 +1894,12 @@ mod tests { ); let stats = commit.compute_partition_stats(&[entry]).unwrap(); - let min_row = BinaryRow::from_serialized_bytes(stats.min_values()) - .expect("min_values must decode as a BinaryRow for the Java reader"); - let max_row = BinaryRow::from_serialized_bytes(stats.max_values()) - .expect("max_values must decode as a BinaryRow for the Java reader"); - assert_eq!( - min_row.arity(), - 1, - "min row arity must match the partition schema" - ); - assert_eq!( - max_row.arity(), - 1, - "max row arity must match the partition schema" - ); - assert!( - min_row.is_null_at(0), - "min value must be NULL since the only input was NULL" - ); - assert!( - max_row.is_null_at(0), - "max value must be NULL since the only input was NULL" - ); - assert_eq!( - stats.null_counts(), - &vec![Some(1)], - "the single all-null entry should be counted", - ); + let min_row = BinaryRow::from_serialized_bytes(stats.min_values()).unwrap(); + let max_row = BinaryRow::from_serialized_bytes(stats.max_values()).unwrap(); + assert_eq!(min_row.arity(), 1); + assert_eq!(max_row.arity(), 1); + assert!(min_row.is_null_at(0)); + assert!(max_row.is_null_at(0)); + assert_eq!(stats.null_counts(), &vec![Some(1)]); } }