This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-mosaic.git


The following commit(s) were added to refs/heads/main by this push:
     new b36d1a9  perf(core): read non-nested columns without creating Arrow 
arrays (#76)
b36d1a9 is described below

commit b36d1a9300d8dabe17db22e28690f88c92d65a82
Author: jianguotian <[email protected]>
AuthorDate: Thu Aug 20 15:06:03 2026 +0800

    perf(core): read non-nested columns without creating Arrow arrays (#76)
---
 core/src/bucket_reader.rs | 557 ++++++++++++++++++++++++++++++++++++++++++++++
 core/src/reader.rs        |  79 ++++++-
 core/src/reader_tests.rs  | 527 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 1161 insertions(+), 2 deletions(-)

diff --git a/core/src/bucket_reader.rs b/core/src/bucket_reader.rs
index 4dbaab4..8ea8bde 100644
--- a/core/src/bucket_reader.rs
+++ b/core/src/bucket_reader.rs
@@ -23,11 +23,335 @@ use arrow_array::*;
 use arrow_buffer::{BooleanBuffer, Buffer, NullBuffer, OffsetBuffer, 
ScalarBuffer};
 use arrow_schema::{DataType, Field, TimeUnit};
 
+use crate::reader::Encoding;
 use crate::spec::*;
 use crate::types;
 use crate::values::Value;
 use crate::varint;
 
+/// Borrowed view of one encoded scalar column.
+///
+/// The view borrows buffers owned by a [`crate::reader::RowGroupReader`] and 
must be consumed
+/// synchronously during 
[`crate::reader::RowGroupReader::visit_encoded_columns`]. It exposes the
+/// physical encoding without first materializing an Arrow array.
+#[derive(Clone, Copy)]
+pub struct EncodedColumn<'a> {
+    data_type: &'a DataType,
+    encoding: u8,
+    has_nulls: bool,
+    null_bitmap: &'a [u8],
+    const_value: &'a Value,
+    dict_values: &'a [Value],
+    dict_bit_width: usize,
+    data: &'a [u8],
+    data_cursor: usize,
+    num_rows: usize,
+}
+
+impl<'a> EncodedColumn<'a> {
+    #[allow(clippy::too_many_arguments)]
+    fn new(
+        data_type: &'a DataType,
+        encoding: u8,
+        has_nulls: bool,
+        null_bitmap: &'a [u8],
+        const_value: &'a Value,
+        dict_values: &'a [Value],
+        dict_bit_width: usize,
+        data: &'a [u8],
+        data_cursor: usize,
+        num_rows: usize,
+    ) -> Self {
+        Self {
+            data_type,
+            encoding,
+            has_nulls,
+            null_bitmap,
+            const_value,
+            dict_values,
+            dict_bit_width,
+            data,
+            data_cursor,
+            num_rows,
+        }
+    }
+
+    pub fn data_type(&self) -> &DataType {
+        self.data_type
+    }
+
+    /// Returns the physical encoding used by this column.
+    pub fn encoding(&self) -> Encoding {
+        Encoding::from_code(self.encoding)
+    }
+
+    /// Returns the logical row count.
+    pub fn num_rows(&self) -> usize {
+        self.num_rows
+    }
+
+    /// Returns whether at least one row is null.
+    pub fn has_nulls(&self) -> bool {
+        self.has_nulls || self.encoding == ENCODING_ALL_NULL
+    }
+
+    /// Returns the physical null bitmap when one is present.
+    ///
+    /// A set bit means that the corresponding row is null.
+    pub fn null_bitmap(&self) -> Option<&'a [u8]> {
+        self.has_nulls.then_some(self.null_bitmap)
+    }
+
+    /// Returns whether `row` is null.
+    ///
+    /// # Panics
+    ///
+    /// Panics if `row >= self.num_rows()`.
+    pub fn is_null(&self, row: usize) -> bool {
+        assert!(row < self.num_rows, "encoded column row out of bounds");
+        self.encoding == ENCODING_ALL_NULL || (self.has_nulls && 
is_null(self.null_bitmap, row))
+    }
+
+    /// Returns the single encoded value for a CONST column.
+    pub fn constant(&self) -> io::Result<Option<EncodedValueRef<'a>>> {
+        if self.encoding != ENCODING_CONST {
+            return Ok(None);
+        }
+        encoded_value_ref(self.data_type, self.const_value).map(Some)
+    }
+
+    /// Iterates values in logical row order without materializing an Arrow 
array.
+    ///
+    /// Null rows are returned as [`EncodedValueRef::Null`]. Dictionary 
indexes and PLAIN values
+    /// are decoded lazily as the iterator advances.
+    pub fn values(&self) -> EncodedColumnValues<'a> {
+        EncodedColumnValues {
+            column: *self,
+            row: 0,
+            data_cursor: self.data_cursor,
+            bit_offset: 0,
+            done: false,
+        }
+    }
+}
+
+/// Borrowed scalar value returned by [`EncodedColumnValues`].
+#[derive(Clone, Copy, Debug, PartialEq)]
+#[non_exhaustive]
+pub enum EncodedValueRef<'a> {
+    Null,
+    Boolean(bool),
+    Int8(i8),
+    Int16(i16),
+    Int32(i32),
+    Int64(i64),
+    Float32(f32),
+    Float64(f64),
+    Utf8(&'a [u8]),
+    Binary(&'a [u8]),
+    DecimalCompact(i64),
+    DecimalLarge(&'a [u8]),
+    Date32(i32),
+    Time32(i32),
+    TimestampMillis(i64),
+    TimestampMicros(i64),
+    TimestampNanos { millis: i64, nanos_of_milli: i32 },
+}
+
+/// Iterator over an [`EncodedColumn`] in logical row order.
+pub struct EncodedColumnValues<'a> {
+    column: EncodedColumn<'a>,
+    row: usize,
+    data_cursor: usize,
+    bit_offset: usize,
+    done: bool,
+}
+
+impl<'a> Iterator for EncodedColumnValues<'a> {
+    type Item = io::Result<EncodedValueRef<'a>>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if self.done || self.row >= self.column.num_rows {
+            return None;
+        }
+
+        let row = self.row;
+        self.row += 1;
+        if self.column.is_null(row) {
+            return Some(Ok(EncodedValueRef::Null));
+        }
+
+        let value = match self.column.encoding {
+            ENCODING_CONST => encoded_value_ref(self.column.data_type, 
self.column.const_value),
+            ENCODING_DICT => read_bit_packed_checked(
+                self.column.data,
+                self.column.data_cursor,
+                self.bit_offset,
+                self.column.dict_bit_width,
+            )
+            .and_then(|index| {
+                self.bit_offset += self.column.dict_bit_width;
+                self.column
+                    .dict_values
+                    .get(index)
+                    .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, 
"corrupt dict index"))
+                    .and_then(|value| encoded_value_ref(self.column.data_type, 
value))
+            }),
+            ENCODING_PLAIN => {
+                let result =
+                    read_encoded_value(self.column.data_type, 
self.column.data, self.data_cursor);
+                if let Ok((_, size)) = result {
+                    self.data_cursor += size;
+                }
+                result.map(|(value, _)| value)
+            }
+            ENCODING_ALL_NULL => Ok(EncodedValueRef::Null),
+            _ => Err(io::Error::new(
+                io::ErrorKind::InvalidData,
+                format!("unsupported encoding {}", self.column.encoding),
+            )),
+        };
+        if value.is_err() {
+            self.done = true;
+        }
+        Some(value)
+    }
+}
+
+fn encoded_value_ref<'a>(
+    data_type: &DataType,
+    value: &'a Value,
+) -> io::Result<EncodedValueRef<'a>> {
+    let value = match value {
+        Value::Null => EncodedValueRef::Null,
+        Value::Boolean(value) => EncodedValueRef::Boolean(*value),
+        Value::TinyInt(value) => EncodedValueRef::Int8(*value),
+        Value::SmallInt(value) => EncodedValueRef::Int16(*value),
+        Value::Integer(value) => EncodedValueRef::Int32(*value),
+        Value::BigInt(value) => EncodedValueRef::Int64(*value),
+        Value::Float(value) => EncodedValueRef::Float32(*value),
+        Value::Double(value) => EncodedValueRef::Float64(*value),
+        Value::Date(value) => EncodedValueRef::Date32(*value),
+        Value::Time(value) => EncodedValueRef::Time32(*value),
+        Value::String(value) => EncodedValueRef::Utf8(value),
+        Value::Bytes(value) => EncodedValueRef::Binary(value),
+        Value::DecimalCompact(value) => 
EncodedValueRef::DecimalCompact(*value),
+        Value::DecimalLarge(value) => EncodedValueRef::DecimalLarge(value),
+        Value::TimestampMillis(value) => 
EncodedValueRef::TimestampMillis(*value),
+        Value::TimestampMicros(value) => 
EncodedValueRef::TimestampMicros(*value),
+        Value::TimestampNanos {
+            millis,
+            nanos_of_milli,
+        } => EncodedValueRef::TimestampNanos {
+            millis: *millis,
+            nanos_of_milli: *nanos_of_milli,
+        },
+    };
+    validate_encoded_value(data_type, value)
+}
+
+fn validate_encoded_value<'a>(
+    data_type: &DataType,
+    value: EncodedValueRef<'a>,
+) -> io::Result<EncodedValueRef<'a>> {
+    if let EncodedValueRef::TimestampNanos {
+        millis,
+        nanos_of_milli,
+    } = value
+    {
+        if types::is_timestamp_nanos(data_type) {
+            types::millis_nanos_to_ns(millis, nanos_of_milli)?;
+        }
+    }
+    Ok(value)
+}
+
+fn read_encoded_value<'a>(
+    data_type: &DataType,
+    data: &'a [u8],
+    position: usize,
+) -> io::Result<(EncodedValueRef<'a>, usize)> {
+    let width = types::fixed_width(data_type);
+    if width <= 0 {
+        let mut payload = position;
+        let length = varint::decode(data, &mut payload).map_err(|_| {
+            io::Error::new(
+                io::ErrorKind::InvalidData,
+                "truncated varint in variable-length value",
+            )
+        })? as usize;
+        let end = payload
+            .checked_add(length)
+            .filter(|end| *end <= data.len())
+            .ok_or_else(|| {
+                io::Error::new(
+                    io::ErrorKind::InvalidData,
+                    "buffer truncated in variable-length value",
+                )
+            })?;
+        let value = match data_type {
+            DataType::Utf8 => EncodedValueRef::Utf8(&data[payload..end]),
+            DataType::Binary => EncodedValueRef::Binary(&data[payload..end]),
+            DataType::Decimal128(_, _) => 
EncodedValueRef::DecimalLarge(&data[payload..end]),
+            _ => {
+                return Err(io::Error::new(
+                    io::ErrorKind::InvalidData,
+                    format!("unsupported encoded data type: {data_type:?}"),
+                ));
+            }
+        };
+        return Ok((value, end - position));
+    }
+
+    let width = width as usize;
+    let end = position
+        .checked_add(width)
+        .filter(|end| *end <= data.len())
+        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "column data 
truncated"))?;
+    let bytes = &data[position..end];
+    let value = match data_type {
+        DataType::Boolean => EncodedValueRef::Boolean(bytes[0] != 0),
+        DataType::Int8 => EncodedValueRef::Int8(bytes[0] as i8),
+        DataType::Int16 => 
EncodedValueRef::Int16(i16::from_be_bytes([bytes[0], bytes[1]])),
+        DataType::Int32 => {
+            EncodedValueRef::Int32(i32::from_be_bytes([bytes[0], bytes[1], 
bytes[2], bytes[3]]))
+        }
+        DataType::Date32 => {
+            EncodedValueRef::Date32(i32::from_be_bytes([bytes[0], bytes[1], 
bytes[2], bytes[3]]))
+        }
+        DataType::Time32(_) => {
+            EncodedValueRef::Time32(i32::from_be_bytes([bytes[0], bytes[1], 
bytes[2], bytes[3]]))
+        }
+        DataType::Float32 => 
EncodedValueRef::Float32(f32::from_bits(u32::from_be_bytes([
+            bytes[0], bytes[1], bytes[2], bytes[3],
+        ]))),
+        DataType::Int64 => EncodedValueRef::Int64(read_i64(data, position)),
+        DataType::Float64 => 
EncodedValueRef::Float64(f64::from_bits(read_u64(data, position))),
+        DataType::Decimal128(_, _) => 
EncodedValueRef::DecimalCompact(read_i64(data, position)),
+        DataType::Timestamp(TimeUnit::Millisecond, _) => {
+            EncodedValueRef::TimestampMillis(read_i64(data, position))
+        }
+        DataType::Timestamp(TimeUnit::Microsecond, _) => {
+            EncodedValueRef::TimestampMicros(read_i64(data, position))
+        }
+        DataType::Timestamp(TimeUnit::Nanosecond, _) | DataType::Struct(_)
+            if types::is_timestamp_nanos(data_type) =>
+        {
+            EncodedValueRef::TimestampNanos {
+                millis: read_i64(data, position),
+                nanos_of_milli: i32::from_be_bytes([bytes[8], bytes[9], 
bytes[10], bytes[11]]),
+            }
+        }
+        _ => {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidData,
+                format!("unsupported encoded data type: {data_type:?}"),
+            ));
+        }
+    };
+    validate_encoded_value(data_type, value).map(|value| (value, width))
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 enum DataVariant {
     Boolean,
@@ -917,6 +1241,30 @@ pub struct BucketReader {
 }
 
 impl BucketReader {
+    pub(crate) fn encoded_column(&self, column: usize) -> 
io::Result<EncodedColumn<'_>> {
+        if column >= self.total_columns {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidInput,
+                format!(
+                    "column index {} out of range (num_columns={})",
+                    column, self.total_columns
+                ),
+            ));
+        }
+        Ok(EncodedColumn::new(
+            &self.col_types[column],
+            self.encodings[column],
+            self.has_nulls[column],
+            &self.null_bitmaps[column],
+            &self.const_values[column],
+            &self.dict_values[column],
+            self.dict_bit_widths[column],
+            &self.data,
+            self.data_cursors[column],
+            self.col_num_rows(column),
+        ))
+    }
+
     fn col_num_rows(&self, col: usize) -> usize {
         if col < self.num_primary {
             self.num_rows
@@ -1201,6 +1549,21 @@ pub struct ColumnPageReader {
 }
 
 impl ColumnPageReader {
+    pub(crate) fn encoded_column(&self) -> EncodedColumn<'_> {
+        EncodedColumn::new(
+            &self.col_type,
+            self.encoding,
+            self.has_nulls,
+            &self.null_bitmap,
+            &self.const_value,
+            &self.dict_values,
+            self.dict_bit_width,
+            &self.data,
+            self.data_cursor,
+            self.num_rows,
+        )
+    }
+
     pub fn new(
         col_type: DataType,
         encoding: u8,
@@ -1229,6 +1592,15 @@ impl ColumnPageReader {
         page_data_start: usize,
         num_rows: usize,
     ) -> io::Result<Self> {
+        if !matches!(
+            encoding,
+            ENCODING_PLAIN | ENCODING_CONST | ENCODING_DICT | ENCODING_ALL_NULL
+        ) {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidData,
+                format!("column page: unsupported encoding {}", encoding),
+            ));
+        }
         if page_data_start > data.len() {
             return Err(io::Error::new(
                 io::ErrorKind::InvalidData,
@@ -1996,6 +2368,23 @@ fn read_bit_packed(buf: &[u8], byte_base: usize, 
bit_offset: usize, bit_width: u
     value
 }
 
+fn read_bit_packed_checked(
+    buf: &[u8],
+    byte_base: usize,
+    bit_offset: usize,
+    bit_width: usize,
+) -> io::Result<usize> {
+    let bit_end = bit_offset
+        .checked_add(bit_width)
+        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "dict index 
offset overflow"))?;
+    let byte_end = byte_base
+        .checked_add(bit_end.div_ceil(8))
+        .filter(|end| *end <= buf.len())
+        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "truncated 
dict indexes"))?;
+    let _ = byte_end;
+    Ok(read_bit_packed(buf, byte_base, bit_offset, bit_width))
+}
+
 fn bit_width(num_entries: usize) -> usize {
     if num_entries <= 1 {
         return 0;
@@ -2416,3 +2805,171 @@ mod tests {
             .all(|value| value.to_bits() == 0.0f32.to_bits()));
     }
 }
+
+#[cfg(test)]
+mod encoded_column_tests {
+    use super::*;
+
+    #[test]
+    fn column_page_rejects_unknown_encoding_without_panicking() {
+        let result = std::panic::catch_unwind(|| -> io::Result<()> {
+            let page =
+                ColumnPageReader::new(DataType::Int32, 0xff, true, 
Value::Null, Vec::new(), 1)?;
+            page.read_all()?;
+            Ok(())
+        });
+
+        let err = result
+            .expect("unknown column page encoding must not panic")
+            .expect_err("unknown column page encoding must be rejected");
+        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
+        assert!(err.to_string().contains("unsupported encoding 255"));
+    }
+
+    #[test]
+    fn rejects_invalid_timestamp_nanos_for_all_encodings() {
+        let data_type = DataType::Timestamp(TimeUnit::Nanosecond, None);
+        let invalid = Value::TimestampNanos {
+            millis: 0,
+            nanos_of_milli: 1_000_000,
+        };
+        let placeholder = Value::Null;
+
+        let constant = EncodedColumn::new(
+            &data_type,
+            ENCODING_CONST,
+            false,
+            &[],
+            &invalid,
+            &[],
+            0,
+            &[],
+            0,
+            1,
+        );
+        assert_eq!(
+            constant.constant().unwrap_err().kind(),
+            io::ErrorKind::InvalidData
+        );
+        assert_eq!(
+            constant.values().next().unwrap().unwrap_err().kind(),
+            io::ErrorKind::InvalidData
+        );
+
+        let dictionary = EncodedColumn::new(
+            &data_type,
+            ENCODING_DICT,
+            false,
+            &[],
+            &placeholder,
+            std::slice::from_ref(&invalid),
+            0,
+            &[],
+            0,
+            1,
+        );
+        assert_eq!(
+            dictionary.values().next().unwrap().unwrap_err().kind(),
+            io::ErrorKind::InvalidData
+        );
+
+        let mut plain_data = 0i64.to_be_bytes().to_vec();
+        plain_data.extend_from_slice(&1_000_000i32.to_be_bytes());
+        let plain = EncodedColumn::new(
+            &data_type,
+            ENCODING_PLAIN,
+            false,
+            &[],
+            &placeholder,
+            &[],
+            0,
+            &plain_data,
+            0,
+            1,
+        );
+        assert_eq!(
+            plain.values().next().unwrap().unwrap_err().kind(),
+            io::ErrorKind::InvalidData
+        );
+    }
+
+    #[test]
+    fn reports_corrupt_dict_indexes_without_panicking() {
+        let data_type = DataType::Int32;
+        let placeholder = Value::Null;
+        let dict_values = [Value::Integer(7)];
+
+        let truncated = EncodedColumn::new(
+            &data_type,
+            ENCODING_DICT,
+            false,
+            &[],
+            &placeholder,
+            &dict_values,
+            1,
+            &[],
+            0,
+            1,
+        );
+        assert_eq!(
+            truncated.values().next().unwrap().unwrap_err().kind(),
+            io::ErrorKind::InvalidData
+        );
+
+        let out_of_range = EncodedColumn::new(
+            &data_type,
+            ENCODING_DICT,
+            false,
+            &[],
+            &placeholder,
+            &dict_values,
+            1,
+            &[1],
+            0,
+            1,
+        );
+        assert_eq!(
+            out_of_range.values().next().unwrap().unwrap_err().kind(),
+            io::ErrorKind::InvalidData
+        );
+    }
+
+    #[test]
+    fn reports_truncated_plain_values_without_panicking() {
+        let placeholder = Value::Null;
+
+        let fixed = EncodedColumn::new(
+            &DataType::Int64,
+            ENCODING_PLAIN,
+            false,
+            &[],
+            &placeholder,
+            &[],
+            0,
+            &[0; 7],
+            0,
+            1,
+        );
+        assert_eq!(
+            fixed.values().next().unwrap().unwrap_err().kind(),
+            io::ErrorKind::InvalidData
+        );
+
+        let variable = EncodedColumn::new(
+            &DataType::Utf8,
+            ENCODING_PLAIN,
+            false,
+            &[],
+            &placeholder,
+            &[],
+            0,
+            &[3, b'a', b'b'],
+            0,
+            1,
+        );
+        assert_eq!(
+            variable.values().next().unwrap().unwrap_err().kind(),
+            io::ErrorKind::InvalidData
+        );
+    }
+}
diff --git a/core/src/reader.rs b/core/src/reader.rs
index 54ffa9d..4de3fee 100644
--- a/core/src/reader.rs
+++ b/core/src/reader.rs
@@ -22,6 +22,7 @@ use arrow_array::{ArrayRef, RecordBatch, RecordBatchOptions};
 use arrow_schema::{DataType, Field, Schema};
 
 use crate::bucket_reader::{read_typed_value, read_variable_value, 
BucketReader, ColumnPageReader};
+pub use crate::bucket_reader::{EncodedColumn, EncodedColumnValues, 
EncodedValueRef};
 use crate::schema::MosaicSchema;
 use crate::spec::*;
 use crate::stats::{self, ColumnStats};
@@ -224,8 +225,8 @@ pub enum Encoding {
 }
 
 impl Encoding {
-    fn from_code(c: u8) -> Self {
-        match c {
+    pub(crate) fn from_code(code: u8) -> Self {
+        match code {
             ENCODING_PLAIN => Encoding::Plain,
             ENCODING_CONST => Encoding::Const,
             ENCODING_DICT => Encoding::Dict,
@@ -1503,6 +1504,80 @@ impl RowGroupReader {
         self.num_rows
     }
 
+    /// Visits projected scalar columns in output order without materializing 
Arrow arrays.
+    ///
+    /// Each [`EncodedColumn`] borrows this row group's buffers and is valid 
only for the duration
+    /// of the callback. ARRAY and MAP columns are rejected before the first 
callback because they
+    /// are represented by multiple physical columns.
+    pub fn visit_encoded_columns<F>(&self, mut visitor: F) -> io::Result<()>
+    where
+        F: FnMut(&str, &DataType, bool, EncodedColumn<'_>) -> io::Result<()>,
+    {
+        for &global_index in &self.output_order {
+            if self.projected_columns[global_index]
+                && matches!(
+                    self.schema.columns[global_index].data_type,
+                    DataType::List(_) | DataType::Map(_, _)
+                )
+            {
+                return Err(io::Error::new(
+                    io::ErrorKind::Unsupported,
+                    format!(
+                        "encoded access does not support nested column '{}'",
+                        self.schema.columns[global_index].name
+                    ),
+                ));
+            }
+        }
+
+        let mut visited = vec![false; self.num_columns];
+        for &global_index in &self.output_order {
+            if visited[global_index] {
+                continue;
+            }
+            visited[global_index] = true;
+            if !self.projected_columns[global_index] {
+                continue;
+            }
+
+            let column = &self.schema.columns[global_index];
+            let bucket_id = column.bucket_id;
+            let local_index = self.bucket_to_global[bucket_id]
+                .iter()
+                .position(|&index| index == global_index)
+                .ok_or_else(|| {
+                    io::Error::new(
+                        io::ErrorKind::InvalidData,
+                        format!("column {} missing from bucket {}", 
global_index, bucket_id),
+                    )
+                })?;
+            let state = self.bucket_states[bucket_id].as_ref().ok_or_else(|| {
+                io::Error::new(
+                    io::ErrorKind::InvalidData,
+                    format!("projected bucket {} was not loaded", bucket_id),
+                )
+            })?;
+            let encoded = match state {
+                BucketState::Monolithic { reader } => 
reader.encoded_column(local_index)?,
+                BucketState::Paged { column_readers } => column_readers
+                    .get(local_index)
+                    .and_then(Option::as_ref)
+                    .ok_or_else(|| {
+                        io::Error::new(
+                            io::ErrorKind::InvalidData,
+                            format!(
+                                "projected column {} missing from paged bucket 
{}",
+                                global_index, bucket_id
+                            ),
+                        )
+                    })?
+                    .encoded_column(),
+            };
+            visitor(&column.name, &column.data_type, column.nullable, 
encoded)?;
+        }
+        Ok(())
+    }
+
     pub fn read_columns(&mut self) -> io::Result<RecordBatch> {
         let num_cols = self.num_columns;
         let mut arrays: Vec<Option<ArrayRef>> = vec![None; num_cols];
diff --git a/core/src/reader_tests.rs b/core/src/reader_tests.rs
index 87cdbb0..aa01139 100644
--- a/core/src/reader_tests.rs
+++ b/core/src/reader_tests.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 use super::*;
+use crate::reader::{EncodedValueRef, Encoding};
 use crate::writer::{MosaicWriter, OutputFile, WriterOptions};
 use arrow_array::*;
 use arrow_schema::{DataType, Field, Schema, TimeUnit};
@@ -103,6 +104,65 @@ fn values_to_batch(rows: &[Vec<Value>], columns: 
&[(String, DataType, bool)]) ->
     RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays).unwrap()
 }
 
+fn encoded_value_to_owned(value: EncodedValueRef<'_>) -> Value {
+    match value {
+        EncodedValueRef::Null => Value::Null,
+        EncodedValueRef::Boolean(value) => Value::Boolean(value),
+        EncodedValueRef::Int8(value) => Value::TinyInt(value),
+        EncodedValueRef::Int16(value) => Value::SmallInt(value),
+        EncodedValueRef::Int32(value) => Value::Integer(value),
+        EncodedValueRef::Int64(value) => Value::BigInt(value),
+        EncodedValueRef::Float32(value) => Value::Float(value),
+        EncodedValueRef::Float64(value) => Value::Double(value),
+        EncodedValueRef::Utf8(value) => Value::String(value.to_vec()),
+        EncodedValueRef::Binary(value) => Value::Bytes(value.to_vec()),
+        EncodedValueRef::DecimalCompact(value) => Value::DecimalCompact(value),
+        EncodedValueRef::DecimalLarge(value) => 
Value::DecimalLarge(value.to_vec()),
+        EncodedValueRef::Date32(value) => Value::Date(value),
+        EncodedValueRef::Time32(value) => Value::Time(value),
+        EncodedValueRef::TimestampMillis(value) => 
Value::TimestampMillis(value),
+        EncodedValueRef::TimestampMicros(value) => 
Value::TimestampMicros(value),
+        EncodedValueRef::TimestampNanos {
+            millis,
+            nanos_of_milli,
+        } => Value::TimestampNanos {
+            millis,
+            nanos_of_milli,
+        },
+    }
+}
+
+fn encoded_view_to_batch(row_group: &RowGroupReader) -> 
io::Result<RecordBatch> {
+    let mut columns = Vec::new();
+    let mut values_by_column = Vec::new();
+    row_group.visit_encoded_columns(|name, data_type, nullable, column| {
+        columns.push((name.to_string(), data_type.clone(), nullable));
+        values_by_column.push(
+            column
+                .values()
+                .map(|value| value.map(encoded_value_to_owned))
+                .collect::<io::Result<Vec<_>>>()?,
+        );
+        Ok(())
+    })?;
+
+    let mut rows = vec![Vec::with_capacity(columns.len()); 
row_group.num_rows()];
+    for values in values_by_column {
+        for (row, value) in values.into_iter().enumerate() {
+            rows[row].push(value);
+        }
+    }
+    Ok(values_to_batch(&rows, &columns))
+}
+
+fn assert_encoded_view_matches_arrow(reader: 
&MosaicReader<ByteArrayInputFile>) {
+    let mut arrow_row_group = reader.row_group_reader(0).unwrap();
+    let arrow = arrow_row_group.read_columns().unwrap();
+    let encoded_row_group = reader.row_group_reader(0).unwrap();
+    let encoded = encoded_view_to_batch(&encoded_row_group).unwrap();
+    assert_eq!(encoded, arrow);
+}
+
 fn build_array_from_values(rows: &[Vec<Value>], col: usize, dt: &DataType) -> 
Arc<dyn Array> {
     match dt {
         DataType::Boolean => {
@@ -2961,6 +3021,453 @@ fn write_and_read_paged(
     (reader, data)
 }
 
+fn encoded_view_fixture(page_size_threshold: usize) -> 
MosaicReader<ByteArrayInputFile> {
+    let columns = vec![
+        ("plain".to_string(), DataType::Int32, true),
+        ("all_null".to_string(), DataType::Int64, true),
+        ("dict".to_string(), DataType::Utf8, true),
+        ("nullable_const".to_string(), DataType::Int16, true),
+    ];
+    let rows: Vec<Vec<Value>> = (0..200)
+        .map(|i| {
+            vec![
+                if i % 13 == 0 {
+                    Value::Null
+                } else {
+                    Value::Integer(i)
+                },
+                Value::Null,
+                if i % 10 == 0 {
+                    Value::Null
+                } else {
+                    Value::String(["red", "green", "blue"][i as usize % 
3].as_bytes().to_vec())
+                },
+                if i % 4 == 0 {
+                    Value::Null
+                } else {
+                    Value::SmallInt(0)
+                },
+            ]
+        })
+        .collect();
+
+    let out = MemOutputFile::new();
+    let mut writer = MosaicWriter::new(
+        out,
+        &columns_to_arrow_schema(&columns),
+        WriterOptions {
+            num_buckets: 1,
+            page_size_threshold,
+            ..Default::default()
+        },
+    )
+    .unwrap();
+    write_values(&mut writer, &columns, &rows);
+    writer.close().unwrap();
+    let data = writer.output().buf.clone();
+    let len = data.len() as u64;
+    MosaicReader::new(ByteArrayInputFile::new(data), len).unwrap()
+}
+
+fn write_encoded_view_fixture(
+    columns: Vec<(String, DataType, bool)>,
+    rows: &[Vec<Value>],
+    page_size_threshold: usize,
+) -> MosaicReader<ByteArrayInputFile> {
+    let out = MemOutputFile::new();
+    let mut writer = MosaicWriter::new(
+        out,
+        &columns_to_arrow_schema(&columns),
+        WriterOptions {
+            num_buckets: 1,
+            page_size_threshold,
+            ..Default::default()
+        },
+    )
+    .unwrap();
+    write_values(&mut writer, &columns, rows);
+    writer.close().unwrap();
+    let data = writer.output().buf.clone();
+    MosaicReader::new(ByteArrayInputFile::new(data.clone()), data.len() as 
u64).unwrap()
+}
+
+fn encoded_scalar_types() -> Vec<(&'static str, DataType)> {
+    vec![
+        ("bool", DataType::Boolean),
+        ("i8", DataType::Int8),
+        ("i16", DataType::Int16),
+        ("i32", DataType::Int32),
+        ("i64", DataType::Int64),
+        ("f32", DataType::Float32),
+        ("f64", DataType::Float64),
+        ("utf8", DataType::Utf8),
+        ("binary", DataType::Binary),
+        ("decimal_compact", DataType::Decimal128(18, 3)),
+        ("decimal_large", DataType::Decimal128(30, 4)),
+        ("date", DataType::Date32),
+        ("time", DataType::Time32(TimeUnit::Millisecond)),
+        (
+            "timestamp_millis",
+            DataType::Timestamp(TimeUnit::Millisecond, None),
+        ),
+        (
+            "timestamp_micros",
+            DataType::Timestamp(TimeUnit::Microsecond, None),
+        ),
+        (
+            "timestamp_nanos",
+            DataType::Timestamp(TimeUnit::Nanosecond, None),
+        ),
+        ("legacy_timestamp_nanos", legacy_timestamp_nanos_type()),
+    ]
+}
+
+fn encoded_scalar_value(data_type: &DataType, seed: usize) -> Value {
+    let seed_i64 = seed as i64;
+    match data_type {
+        DataType::Boolean => Value::Boolean(seed & 1 != 0),
+        DataType::Int8 => Value::TinyInt(seed as i8 - 48),
+        DataType::Int16 => Value::SmallInt(seed as i16 * 17 - 700),
+        DataType::Int32 => Value::Integer(seed as i32 * 10_007 - 50_000),
+        DataType::Int64 => Value::BigInt(seed_i64 * 1_000_003 - 9_000_000),
+        DataType::Float32 => Value::Float(seed as f32 * 1.25 - 20.5),
+        DataType::Float64 => Value::Double(seed as f64 * 0.125 - 7.75),
+        DataType::Utf8 => 
Value::String(format!("value-{seed:03}").into_bytes()),
+        DataType::Binary => Value::Bytes(vec![
+            seed as u8,
+            seed.wrapping_mul(17) as u8,
+            seed.wrapping_mul(31) as u8,
+        ]),
+        DataType::Decimal128(precision, _) if *precision <= 18 => {
+            Value::DecimalCompact(seed_i64 * 10_003 - 40_000)
+        }
+        DataType::Decimal128(_, _) => Value::DecimalLarge(
+            ((1i128 << 80) + seed as i128 * 10_007)
+                .to_be_bytes()
+                .to_vec(),
+        ),
+        DataType::Date32 => Value::Date(19_000 + seed as i32),
+        DataType::Time32(_) => Value::Time(1_000 + seed as i32 * 17),
+        DataType::Timestamp(TimeUnit::Millisecond, _) => {
+            Value::TimestampMillis(1_700_000_000_000 + seed_i64)
+        }
+        DataType::Timestamp(TimeUnit::Microsecond, _) => {
+            Value::TimestampMicros(1_700_000_000_000_000 + seed_i64)
+        }
+        DataType::Timestamp(TimeUnit::Nanosecond, _) | DataType::Struct(_)
+            if crate::types::is_timestamp_nanos(data_type) =>
+        {
+            Value::TimestampNanos {
+                millis: 1_700_000_000_000 + seed_i64,
+                nanos_of_milli: (seed * 7_919 % 1_000_000) as i32,
+            }
+        }
+        other => panic!("unsupported encoded scalar type: {other:?}"),
+    }
+}
+
+fn assert_fixture_encodings(
+    reader: &MosaicReader<ByteArrayInputFile>,
+    expected: &[(&str, Encoding)],
+) {
+    let infos = reader.page_infos(0).unwrap();
+    assert_eq!(infos.len(), expected.len());
+    for (name, encoding) in expected {
+        let column_index = reader
+            .schema()
+            .columns
+            .iter()
+            .position(|column| column.name == *name)
+            .unwrap();
+        let info = infos
+            .iter()
+            .find(|info| info.column_index == column_index)
+            .unwrap();
+        assert_eq!(info.encoding, *encoding, "column {name}");
+    }
+}
+
+fn assert_all_scalar_encoded_views(page_size_threshold: usize) {
+    let scalar_types = encoded_scalar_types();
+    let mut columns = Vec::new();
+    let mut expected = Vec::new();
+    for (name, data_type) in &scalar_types {
+        for (suffix, encoding) in [
+            ("all_null", Encoding::AllNull),
+            ("const", Encoding::Const),
+            ("dict", Encoding::Dict),
+        ] {
+            let column_name = format!("{name}_{suffix}");
+            columns.push((column_name.clone(), data_type.clone(), true));
+            expected.push((column_name, encoding));
+        }
+        if *data_type != DataType::Boolean {
+            let column_name = format!("{name}_plain");
+            columns.push((column_name.clone(), data_type.clone(), true));
+            expected.push((column_name, Encoding::Plain));
+        }
+    }
+
+    let rows = (0..96)
+        .map(|row| {
+            let mut values = Vec::with_capacity(columns.len());
+            for (_, data_type) in &scalar_types {
+                values.push(Value::Null);
+                values.push(if row % 5 == 0 {
+                    Value::Null
+                } else {
+                    encoded_scalar_value(data_type, 7)
+                });
+                values.push(if row % 11 == 0 {
+                    Value::Null
+                } else {
+                    encoded_scalar_value(data_type, row % 3)
+                });
+                if *data_type != DataType::Boolean {
+                    values.push(if row % 17 == 0 {
+                        Value::Null
+                    } else {
+                        encoded_scalar_value(data_type, row)
+                    });
+                }
+            }
+            values
+        })
+        .collect::<Vec<_>>();
+
+    let reader = write_encoded_view_fixture(columns, &rows, 
page_size_threshold);
+    let expected_refs = expected
+        .iter()
+        .map(|(name, encoding)| (name.as_str(), *encoding))
+        .collect::<Vec<_>>();
+    assert_fixture_encodings(&reader, &expected_refs);
+    assert_encoded_view_matches_arrow(&reader);
+
+    let bool_columns = vec![
+        ("bool_all_null".to_string(), DataType::Boolean, true),
+        ("bool_const".to_string(), DataType::Boolean, true),
+        ("bool_plain".to_string(), DataType::Boolean, true),
+        ("plain_anchor".to_string(), DataType::Int32, true),
+    ];
+    let bool_rows = vec![
+        vec![
+            Value::Null,
+            Value::Boolean(true),
+            Value::Boolean(true),
+            Value::Integer(1),
+        ],
+        vec![
+            Value::Null,
+            Value::Null,
+            Value::Boolean(false),
+            Value::Integer(2),
+        ],
+    ];
+    let bool_reader = write_encoded_view_fixture(bool_columns, &bool_rows, 
page_size_threshold);
+    assert_fixture_encodings(
+        &bool_reader,
+        &[
+            ("bool_all_null", Encoding::AllNull),
+            ("bool_const", Encoding::Const),
+            ("bool_plain", Encoding::Plain),
+            ("plain_anchor", Encoding::Plain),
+        ],
+    );
+    assert_encoded_view_matches_arrow(&bool_reader);
+}
+
+fn assert_encoded_view(reader: &MosaicReader<ByteArrayInputFile>) {
+    assert_encoded_view_matches_arrow(reader);
+    let row_group = reader.row_group_reader(0).unwrap();
+    let mut names = Vec::new();
+    row_group
+        .visit_encoded_columns(|name, data_type, nullable, column| {
+            names.push(name.to_string());
+            assert_eq!(column.data_type(), data_type);
+            assert_eq!(column.num_rows(), 200);
+            assert!(nullable);
+
+            match name {
+                "plain" => {
+                    assert_eq!(column.encoding(), Encoding::Plain);
+                    assert!(column.has_nulls());
+                    let null_bitmap = column.null_bitmap().unwrap();
+                    assert_eq!(null_bitmap.len(), 25);
+                    assert_eq!(column.constant().unwrap(), None);
+                    for (row, value) in column.values().enumerate() {
+                        let expected_null = row % 13 == 0;
+                        let expected = if row % 13 == 0 {
+                            EncodedValueRef::Null
+                        } else {
+                            EncodedValueRef::Int32(row as i32)
+                        };
+                        assert_eq!(value.unwrap(), expected);
+                        assert_eq!(null_bitmap[row / 8] & (1 << (row % 8)) != 
0, expected_null);
+                        assert_eq!(column.is_null(row), expected_null);
+                    }
+                }
+                "all_null" => {
+                    assert_eq!(column.encoding(), Encoding::AllNull);
+                    assert!(column.has_nulls());
+                    assert_eq!(column.null_bitmap(), None);
+                    assert_eq!(column.constant().unwrap(), None);
+                    for (row, value) in column.values().enumerate() {
+                        assert_eq!(value.unwrap(), EncodedValueRef::Null);
+                        assert!(column.is_null(row));
+                    }
+                }
+                "dict" => {
+                    assert_eq!(column.encoding(), Encoding::Dict);
+                    assert!(column.has_nulls());
+                    let null_bitmap = column.null_bitmap().unwrap();
+                    assert_eq!(null_bitmap.len(), 25);
+                    assert_eq!(column.constant().unwrap(), None);
+                    for (row, value) in column.values().enumerate() {
+                        let expected_null = row % 10 == 0;
+                        let expected = if row % 10 == 0 {
+                            EncodedValueRef::Null
+                        } else {
+                            EncodedValueRef::Utf8(["red", "green", "blue"][row 
% 3].as_bytes())
+                        };
+                        assert_eq!(value.unwrap(), expected);
+                        assert_eq!(null_bitmap[row / 8] & (1 << (row % 8)) != 
0, expected_null);
+                        assert_eq!(column.is_null(row), expected_null);
+                    }
+                }
+                "nullable_const" => {
+                    assert_eq!(column.encoding(), Encoding::Const);
+                    assert!(column.has_nulls());
+                    let null_bitmap = column.null_bitmap().unwrap();
+                    assert_eq!(null_bitmap.len(), 25);
+                    assert_eq!(column.constant().unwrap(), 
Some(EncodedValueRef::Int16(0)));
+                    for (row, value) in column.values().enumerate() {
+                        let expected_null = row % 4 == 0;
+                        let expected = if row % 4 == 0 {
+                            EncodedValueRef::Null
+                        } else {
+                            EncodedValueRef::Int16(0)
+                        };
+                        assert_eq!(value.unwrap(), expected);
+                        assert_eq!(null_bitmap[row / 8] & (1 << (row % 8)) != 
0, expected_null);
+                        assert_eq!(column.is_null(row), expected_null);
+                    }
+                }
+                other => panic!("unexpected encoded column {other}"),
+            }
+            Ok(())
+        })
+        .unwrap();
+    assert_eq!(names, ["plain", "all_null", "dict", "nullable_const"]);
+
+    let projected = reader
+        .row_group_reader_by_names(0, &["dict", "nullable_const"])
+        .unwrap();
+    let mut projected_names = Vec::new();
+    projected
+        .visit_encoded_columns(|name, _, _, _| {
+            projected_names.push(name.to_string());
+            Ok(())
+        })
+        .unwrap();
+    assert_eq!(projected_names, ["dict", "nullable_const"]);
+
+    let duplicate = reader
+        .row_group_reader_by_names(0, &["dict", "dict"])
+        .unwrap();
+    let mut duplicate_names = Vec::new();
+    duplicate
+        .visit_encoded_columns(|name, _, _, _| {
+            duplicate_names.push(name.to_string());
+            Ok(())
+        })
+        .unwrap();
+    assert_eq!(duplicate_names, ["dict"]);
+
+    let mut duplicate_arrow = reader
+        .row_group_reader_by_names(0, &["dict", "dict"])
+        .unwrap();
+    let duplicate_batch = duplicate_arrow.read_columns().unwrap();
+    assert_eq!(duplicate_batch.num_columns(), 1);
+    assert_eq!(duplicate_batch.schema().field(0).name(), "dict");
+}
+
+#[test]
+fn test_visit_encoded_columns_monolithic_and_paged() {
+    assert_encoded_view(&encoded_view_fixture(usize::MAX));
+    assert_encoded_view(&encoded_view_fixture(1));
+}
+
+#[test]
+fn test_encoded_view_matches_arrow_for_all_scalar_types_and_encodings() {
+    assert_all_scalar_encoded_views(usize::MAX);
+    assert_all_scalar_encoded_views(1);
+}
+
+#[test]
+fn test_visit_encoded_columns_rejects_nested_before_callback() {
+    let item = Arc::new(Field::new("item", DataType::Int32, true));
+    let schema = Schema::new(vec![
+        Field::new("id", DataType::Int32, false),
+        Field::new("values", DataType::List(item), true),
+    ]);
+    let out = MemOutputFile::new();
+    let mut writer = MosaicWriter::new(
+        out,
+        &schema,
+        WriterOptions {
+            num_buckets: 1,
+            ..Default::default()
+        },
+    )
+    .unwrap();
+    let mut lists =
+        
arrow_array::builder::ListBuilder::new(arrow_array::builder::Int32Builder::new());
+    for i in 0..4 {
+        lists.values().append_value(i);
+        lists.append(true);
+    }
+    let batch = RecordBatch::try_new(
+        Arc::new(schema),
+        vec![
+            Arc::new(Int32Array::from(vec![0, 1, 2, 3])),
+            Arc::new(lists.finish()),
+        ],
+    )
+    .unwrap();
+    writer.write_batch(&batch).unwrap();
+    writer.close().unwrap();
+    let data = writer.output().buf.clone();
+    let reader =
+        MosaicReader::new(ByteArrayInputFile::new(data.clone()), data.len() as 
u64).unwrap();
+
+    let row_group = reader.row_group_reader(0).unwrap();
+    let mut callbacks = 0;
+    let error = row_group
+        .visit_encoded_columns(|_, _, _, _| {
+            callbacks += 1;
+            Ok(())
+        })
+        .unwrap_err();
+    assert_eq!(error.kind(), io::ErrorKind::Unsupported);
+    assert!(error.to_string().contains("values"));
+    assert_eq!(callbacks, 0);
+
+    let id = reader
+        .schema()
+        .columns
+        .iter()
+        .position(|column| column.name == "id")
+        .unwrap();
+    let projected = reader.row_group_reader_projected(0, &[id]).unwrap();
+    projected
+        .visit_encoded_columns(|name, _, _, column| {
+            assert_eq!(name, "id");
+            assert_eq!(column.encoding(), Encoding::Plain);
+            Ok(())
+        })
+        .unwrap();
+}
+
 #[test]
 fn test_paged_roundtrip_basic() {
     let columns = vec![
@@ -4636,3 +5143,23 @@ fn test_slot_sizes_paged_array_column() {
     assert_eq!(projected[0].column_index, vals);
     assert_eq!(projected[0].slot_size, sizes[vals]);
 }
+
+#[test]
+fn test_unknown_paged_encoding_returns_invalid_data_without_panicking() {
+    let result = std::panic::catch_unwind(|| -> io::Result<()> {
+        let page = 
MosaicReader::<ByteArrayInputFile>::parse_simple_column_slot(
+            vec![0xff, 0x01],
+            &DataType::Int32,
+            1,
+        )?;
+        let column = page.encoded_column();
+        column.values().next().transpose()?;
+        Ok(())
+    });
+
+    let err = result
+        .expect("unknown paged encoding must not panic")
+        .expect_err("unknown paged encoding must be rejected");
+    assert_eq!(err.kind(), io::ErrorKind::InvalidData);
+    assert!(err.to_string().contains("unsupported encoding 255"));
+}

Reply via email to