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

yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 1e285c6a fix(reader): read Avro enum columns and V2 Avro data log 
blocks (#803)
1e285c6a is described below

commit 1e285c6a96d139ae1828f691e0591f99842d12b4
Author: Y Ethan Guo <[email protected]>
AuthorDate: Tue Sep 29 17:35:41 2026 -0700

    fix(reader): read Avro enum columns and V2 Avro data log blocks (#803)
    
    Accept V2 Avro data blocks, which writers before table version 6 leave in
    tables upgraded to 6 until compaction; V2 delete blocks are Kryo-encoded and
    stay refused with an error naming the block and version. Unpack arrow-avro's
    enum dictionaries in the Avro block decoder so log rows merge with the Utf8
    column the table schema and base files use.
---
 crates/core/src/file_group/log_file/avro.rs    | 131 ++++++++++++-
 crates/core/src/file_group/log_file/content.rs |  90 +++++++--
 crates/core/tests/log_block_compat_tests.rs    | 250 +++++++++++++++++++++++++
 3 files changed, 453 insertions(+), 18 deletions(-)

diff --git a/crates/core/src/file_group/log_file/avro.rs 
b/crates/core/src/file_group/log_file/avro.rs
index d4e0f8c8..834215f9 100644
--- a/crates/core/src/file_group/log_file/avro.rs
+++ b/crates/core/src/file_group/log_file/avro.rs
@@ -176,9 +176,19 @@ impl AvroBlockDecoder {
     ///
     /// Taken from the decoder rather than converted from the Avro JSON, 
because
     /// `avro_to_arrow` does not handle named-type references and the metadata
-    /// table's schema uses them.
+    /// table's schema uses them. Dictionaries are unpacked to match what
+    /// [`flush`](Self::flush) emits.
     pub fn schema(&self) -> SchemaRef {
-        self.decoder.schema()
+        let schema = self.decoder.schema();
+        if !schema
+            .fields()
+            .iter()
+            .any(|f| has_dictionary(f.data_type()))
+        {
+            return schema;
+        }
+        let fields: Vec<Field> = schema.fields().iter().map(|f| 
unpacked_field(f)).collect();
+        Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone()))
     }
 
     /// Project each decoded batch to `schema` instead of resolving during the
@@ -234,7 +244,11 @@ impl AvroBlockDecoder {
         let batch = self.decoder.flush().map_err(|e| {
             CoreError::LogBlockError(format!("Failed to flush decoded records: 
{e}"))
         })?;
-        let batch = batch.map(normalize_utc_timestamps).transpose()?;
+        let batch = batch
+            .map(normalize_utc_timestamps)
+            .transpose()?
+            .map(unpack_dictionaries)
+            .transpose()?;
         match (batch, self.rewrite_to.as_ref()) {
             (Some(batch), Some(target)) => {
                 
crate::schema::batch_evolution::project_batch_to_schema(&batch, 
target).map(Some)
@@ -300,6 +314,71 @@ fn normalize_utc_timestamps(batch: RecordBatch) -> 
Result<RecordBatch> {
     RecordBatch::try_new(Arc::new(Schema::new(fields)), 
columns).map_err(CoreError::ArrowError)
 }
 
+/// Unpack every dictionary column, at any depth, to its value type.
+///
+/// `arrow-avro` decodes an Avro `enum` as `Dictionary(Int32, Utf8)`, while the
+/// table schema (`avro_to_arrow::schema`) and a parquet base file both hold 
the
+/// same column as `Utf8`. The merge reconciles log rows to the table schema,
+/// which refuses a dictionary leaf, so the log batch is unpacked here.
+fn unpack_dictionaries(batch: RecordBatch) -> Result<RecordBatch> {
+    let schema = batch.schema();
+    if !schema
+        .fields()
+        .iter()
+        .any(|f| has_dictionary(f.data_type()))
+    {
+        return Ok(batch);
+    }
+
+    let mut fields: Vec<Field> = Vec::with_capacity(batch.num_columns());
+    let mut columns: Vec<ArrayRef> = Vec::with_capacity(batch.num_columns());
+    for (field, column) in schema.fields().iter().zip(batch.columns()) {
+        if has_dictionary(field.data_type()) {
+            let target = unpacked_field(field);
+            columns.push(cast(column, 
target.data_type()).map_err(CoreError::ArrowError)?);
+            fields.push(target);
+        } else {
+            fields.push(field.as_ref().clone());
+            columns.push(column.clone());
+        }
+    }
+    RecordBatch::try_new(
+        Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone())),
+        columns,
+    )
+    .map_err(CoreError::ArrowError)
+}
+
+fn has_dictionary(data_type: &DataType) -> bool {
+    match data_type {
+        DataType::Dictionary(_, _) => true,
+        DataType::Struct(fields) => fields.iter().any(|f| 
has_dictionary(f.data_type())),
+        DataType::List(f)
+        | DataType::LargeList(f)
+        | DataType::FixedSizeList(f, _)
+        | DataType::Map(f, _) => has_dictionary(f.data_type()),
+        _ => false,
+    }
+}
+
+fn unpacked_field(field: &Field) -> Field {
+    field.clone().with_data_type(unpacked(field.data_type()))
+}
+
+fn unpacked(data_type: &DataType) -> DataType {
+    match data_type {
+        DataType::Dictionary(_, value_type) => unpacked(value_type),
+        DataType::Struct(fields) => {
+            DataType::Struct(fields.iter().map(|f| 
unpacked_field(f)).collect())
+        }
+        DataType::List(f) => DataType::List(Arc::new(unpacked_field(f))),
+        DataType::LargeList(f) => 
DataType::LargeList(Arc::new(unpacked_field(f))),
+        DataType::FixedSizeList(f, n) => 
DataType::FixedSizeList(Arc::new(unpacked_field(f)), *n),
+        DataType::Map(f, sorted) => DataType::Map(Arc::new(unpacked_field(f)), 
*sorted),
+        other => other.clone(),
+    }
+}
+
 #[cfg(test)]
 mod multi_batch_tests {
     use super::*;
@@ -494,6 +573,52 @@ mod tests {
         );
     }
 
+    /// An Avro enum decodes as a string dictionary; it leaves the decoder as
+    /// plain strings at every depth, matching the table schema's `Utf8`.
+    #[test]
+    fn test_enum_decodes_to_utf8_at_every_depth() {
+        use apache_avro::types::Value;
+        use arrow_array::cast::AsArray;
+        use arrow_schema::DataType;
+
+        let schema = r#"{"type":"record","name":"r","fields":[
+            
{"name":"e","type":{"type":"enum","name":"Kind","symbols":["A","B"]}},
+            {"name":"n","type":["null","Kind"],"default":null},
+            
{"name":"s","type":{"type":"record","name":"s","fields":[{"name":"k","type":"Kind"}]}},
+            {"name":"a","type":{"type":"array","items":"Kind"}},
+            {"name":"m","type":{"type":"map","values":"Kind"}}]}"#;
+        let avro_schema = apache_avro::Schema::parse_str(schema).unwrap();
+        let kind = |i: u32| Value::Enum(i, ["A", "B"][i as usize].to_string());
+        let record = Value::Record(vec![
+            ("e".into(), kind(1)),
+            ("n".into(), Value::Union(0, Box::new(Value::Null))),
+            ("s".into(), Value::Record(vec![("k".into(), kind(0))])),
+            ("a".into(), Value::Array(vec![kind(0), kind(1)])),
+            ("m".into(), Value::Map([("x".to_string(), kind(1))].into())),
+        ]);
+        let body = apache_avro::to_avro_datum(&avro_schema, record).unwrap();
+
+        let mut decoder =
+            AvroBlockDecoder::try_new_with_reader(schema, Some(schema), 
1024).unwrap();
+        decoder.decode(&body).unwrap();
+        let declared = decoder.schema();
+        let batch = decoder.flush().unwrap().expect("a batch");
+        assert_eq!(declared.fields(), batch.schema().fields());
+
+        // `as_string` panics on a dictionary column, so each read below also
+        // pins the type.
+        assert_eq!(batch.column(0).as_string::<i32>().value(0), "B");
+        assert_eq!(batch.column(1).data_type(), &DataType::Utf8);
+        assert!(batch.column(1).is_null(0));
+        let s = batch.column(2).as_struct();
+        assert_eq!(s.column(0).as_string::<i32>().value(0), "A");
+        let a = batch.column(3).as_list::<i32>().value(0);
+        let a = a.as_string::<i32>();
+        assert_eq!((a.value(0), a.value(1)), ("A", "B"));
+        let m = batch.column(4).as_map();
+        assert_eq!(m.values().as_string::<i32>().value(0), "B");
+    }
+
     /// Without a reader schema the block reads at the schema it was written
     /// with, which is what a partial-update block relies on.
     #[test]
diff --git a/crates/core/src/file_group/log_file/content.rs 
b/crates/core/src/file_group/log_file/content.rs
index 63669db9..553e18a7 100644
--- a/crates/core/src/file_group/log_file/content.rs
+++ b/crates/core/src/file_group/log_file/content.rs
@@ -264,19 +264,24 @@ impl Decoder {
         }
     }
 
-    /// Validate the log block version (first 4 bytes of block content).
+    /// Read the log block version (first 4 bytes of block content) and check 
it
+    /// is one of `supported`.
     ///
     /// This is NOT the same as [`LogFormatVersion`] (read from the file 
header).
-    /// Modern Hudi tables use [`LogBlockVersion::V3`].
-    fn validate_log_block_version(mut reader: impl Read) -> Result<()> {
+    /// Modern Hudi tables use [`LogBlockVersion::V3`]; writers before table
+    /// version 6 (Hudi 0.13 and earlier) use [`LogBlockVersion::V2`], and a 
table
+    /// upgraded from them keeps those blocks until compaction rewrites them.
+    fn validate_log_block_version(
+        mut reader: impl Read,
+        supported: &[LogBlockVersion],
+        block: &str,
+    ) -> Result<()> {
         let mut version_buf = [0u8; 4];
         reader.read_exact(&mut version_buf)?;
         let version = LogBlockVersion::try_from(version_buf)?;
-        if version != LogBlockVersion::V3 {
+        if !supported.contains(&version) {
             return Err(CoreError::LogBlockError(format!(
-                "Only support log block version {} but got: {:?}",
-                LogBlockVersion::V3 as u32,
-                version
+                "Unsupported {block} block version {version:?}; supported: 
{supported:?}"
             )));
         }
         Ok(())
@@ -435,7 +440,14 @@ impl Decoder {
         mut reader: impl Read,
         header: &HashMap<BlockMetadataKey, String>,
     ) -> Result<RecordBatches> {
-        Decoder::validate_log_block_version(&mut reader)?;
+        // V2 and V3 Avro data blocks share one layout (version, record count,
+        // records): Hudi's HoodieAvroDataBlockVersion reports a record count 
for
+        // every version.
+        Decoder::validate_log_block_version(
+            &mut reader,
+            &[LogBlockVersion::V2, LogBlockVersion::V3],
+            "Avro data",
+        )?;
 
         let writer_schema_json = 
header.get(&BlockMetadataKey::Schema).ok_or_else(|| {
             CoreError::LogBlockError("Schema not found in block 
header".to_string())
@@ -511,7 +523,9 @@ impl Decoder {
         mut reader: impl Read,
         header: &HashMap<BlockMetadataKey, String>,
     ) -> Result<RecordBatches> {
-        Decoder::validate_log_block_version(&mut reader)?;
+        // A V2 delete block holds Kryo-serialized DeleteRecord objects rather 
than
+        // the Avro HoodieDeleteRecordList of V3, which this reader cannot 
decode.
+        Decoder::validate_log_block_version(&mut reader, 
&[LogBlockVersion::V3], "delete")?;
 
         let mut datum_len = [0u8; 4];
         reader.read_exact(&mut datum_len)?;
@@ -898,24 +912,70 @@ mod tests {
         );
     }
 
-    /// The block version is the first four bytes of a block's content, and 
only
-    /// V3 is understood. A block at another version is refused rather than
-    /// decoded as if it were V3, which would misread every byte after it.
+    /// The block version is the first four bytes of a block's content. An Avro
+    /// data block is understood at V2 and V3; any other version is refused 
rather
+    /// than decoded as if it were V3, which would misread every byte after it.
     #[test]
-    fn test_decode_content_rejects_a_non_v3_block_version() {
+    fn test_decode_content_rejects_an_unsupported_block_version() {
         let mut buf = Vec::new();
         buf.extend_from_slice(&1u32.to_be_bytes());
         buf.extend_from_slice(&0u32.to_be_bytes());
         let header = HashMap::from([(BlockMetadataKey::Schema, 
"{}".to_string())]);
         let err = Decoder::new(Arc::new(HudiConfigs::empty()))
             .decode_avro_record_content(buf.as_slice(), &header)
-            .expect_err("a non-V3 block must be refused");
+            .expect_err("a V1 block must be refused");
         assert!(
-            err.to_string().contains("log block version"),
+            err.to_string().contains("Avro data block version V1"),
             "the error must name the version, got: {err}"
         );
     }
 
+    /// A table version 5 writer stamps its Avro data blocks V2, with the same
+    /// layout as V3 (version, record count, records), so they decode the same.
+    #[test]
+    fn test_decode_avro_content_at_block_version_2() -> Result<()> {
+        let schema_str = r#"{"type": "record", "name": "TestRecord",
+            "fields": [{"name": "id", "type": "long"}]}"#;
+        let writer_schema = apache_avro::Schema::parse_str(schema_str)?;
+        let mut buf = Vec::new();
+        buf.extend_from_slice(&2u32.to_be_bytes());
+        buf.extend_from_slice(&2u32.to_be_bytes());
+        for id in [7i64, 8] {
+            let mut record = AvroRecord::new(&writer_schema).unwrap();
+            record.put("id", id);
+            let bytes = to_avro_datum(&writer_schema, record)?;
+            buf.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
+            buf.extend_from_slice(&bytes);
+        }
+        let header = HashMap::from([(BlockMetadataKey::Schema, 
schema_str.to_string())]);
+        let batches = Decoder::new(Arc::new(HudiConfigs::empty()))
+            .decode_avro_record_content(Cursor::new(buf), &header)?;
+        assert_eq!(batches.num_data_rows(), 2);
+        let ids = batches.data_batches[0]
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!((ids.value(0), ids.value(1)), (7, 8));
+        Ok(())
+    }
+
+    /// A V2 delete block holds Kryo-serialized records, not the Avro list of 
V3,
+    /// so it is refused by name rather than misread as Avro.
+    #[test]
+    fn test_decode_delete_content_rejects_block_version_2() {
+        let mut buf = Vec::new();
+        buf.extend_from_slice(&2u32.to_be_bytes());
+        buf.extend_from_slice(&0u32.to_be_bytes());
+        let err = Decoder::new(Arc::new(HudiConfigs::empty()))
+            .decode_delete_record_content(buf.as_slice(), &HashMap::new())
+            .expect_err("a V2 delete block must be refused");
+        assert!(
+            err.to_string().contains("delete block version V2"),
+            "the error must name the block and version, got: {err}"
+        );
+    }
+
     /// An Avro block records the schema it was written with in its header. 
With
     /// none there is nothing to decode against, so the read stops rather than
     /// guessing at the table's current schema — which would misread a block
diff --git a/crates/core/tests/log_block_compat_tests.rs 
b/crates/core/tests/log_block_compat_tests.rs
new file mode 100644
index 00000000..ef792983
--- /dev/null
+++ b/crates/core/tests/log_block_compat_tests.rs
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+//! Merge-on-read reads of log blocks built byte by byte, for block shapes no
+//! checked-in fixture carries: an older log block version and an Avro `enum`
+//! column that the base file stores as a string.
+
+use std::fs;
+use std::path::Path;
+use std::sync::Arc;
+
+use apache_avro::types::Value;
+use arrow::compute::concat_batches;
+use arrow_array::{ArrayRef, Int64Array, RecordBatch, StringArray};
+use arrow_schema::{DataType, Field, Schema};
+use hudi_core::error::Result;
+use hudi_core::table::{ReadOptions, Table};
+use parquet::arrow::ArrowWriter;
+
+const BASE_INSTANT: &str = "20250101000000000";
+const LOG_INSTANT: &str = "20250102000000000";
+const FILE_ID: &str = "f0000000-0000-0000-0000-000000000000-0";
+
+const AVRO_SCHEMA: &str = 
r#"{"type":"record","name":"EnumRecord","namespace":"test","fields":[
+{"name":"_hoodie_commit_time","type":["null","string"],"default":null},
+{"name":"_hoodie_commit_seqno","type":["null","string"],"default":null},
+{"name":"_hoodie_record_key","type":["null","string"],"default":null},
+{"name":"_hoodie_partition_path","type":["null","string"],"default":null},
+{"name":"_hoodie_file_name","type":["null","string"],"default":null},
+{"name":"id","type":"string"},
+{"name":"kind","type":{"type":"enum","name":"Kind","symbols":["A","B","C"]}},
+{"name":"ts","type":"long"}]}"#;
+
+const SYMBOLS: [&str; 3] = ["A", "B", "C"];
+
+fn meta(v: &str) -> Value {
+    Value::Union(1, Box::new(Value::String(v.to_string())))
+}
+
+fn log_record(id: &str, kind: &str, ts: i64) -> Value {
+    let idx = SYMBOLS.iter().position(|s| *s == kind).unwrap() as u32;
+    Value::Record(vec![
+        ("_hoodie_commit_time".into(), meta(LOG_INSTANT)),
+        (
+            "_hoodie_commit_seqno".into(),
+            meta(&format!("{LOG_INSTANT}_0_{id}")),
+        ),
+        ("_hoodie_record_key".into(), meta(id)),
+        ("_hoodie_partition_path".into(), meta("")),
+        ("_hoodie_file_name".into(), meta("")),
+        ("id".into(), Value::String(id.to_string())),
+        ("kind".into(), Value::Enum(idx, kind.to_string())),
+        ("ts".into(), Value::Long(ts)),
+    ])
+}
+
+/// One log file holding a single Avro data block stamped `block_version`.
+fn write_log_file(path: &Path, block_version: u32, records: &[(&str, &str, 
i64)]) -> usize {
+    let schema = apache_avro::Schema::parse_str(AVRO_SCHEMA).unwrap();
+    let mut content = Vec::new();
+    content.extend_from_slice(&block_version.to_be_bytes());
+    content.extend_from_slice(&(records.len() as u32).to_be_bytes());
+    for (id, kind, ts) in records {
+        let datum = apache_avro::to_avro_datum(&schema, log_record(id, kind, 
*ts)).unwrap();
+        content.extend_from_slice(&(datum.len() as u32).to_be_bytes());
+        content.extend_from_slice(&datum);
+    }
+
+    let mut inner = Vec::new();
+    inner.extend_from_slice(&1u32.to_be_bytes()); // log format version
+    inner.extend_from_slice(&3u32.to_be_bytes()); // AvroData block
+    inner.extend_from_slice(&2u32.to_be_bytes()); // header entries
+    for (key, value) in [(0u32, LOG_INSTANT), (2u32, AVRO_SCHEMA)] {
+        inner.extend_from_slice(&key.to_be_bytes());
+        inner.extend_from_slice(&(value.len() as u32).to_be_bytes());
+        inner.extend_from_slice(value.as_bytes());
+    }
+    inner.extend_from_slice(&(content.len() as u64).to_be_bytes());
+    inner.extend_from_slice(&content);
+    inner.extend_from_slice(&0u32.to_be_bytes()); // footer entries
+
+    let block_length = (inner.len() + 8) as u64;
+    let mut out = Vec::new();
+    out.extend_from_slice(b"#HUDI#");
+    out.extend_from_slice(&block_length.to_be_bytes());
+    out.extend_from_slice(&inner);
+    out.extend_from_slice(&(block_length + 6).to_be_bytes());
+    fs::write(path, &out).unwrap();
+    out.len()
+}
+
+fn write_base_file(path: &Path, rows: &[(&str, &str, i64)]) -> u64 {
+    let utf8 = |name| Field::new(name, DataType::Utf8, true);
+    let schema = Arc::new(Schema::new(vec![
+        utf8("_hoodie_commit_time"),
+        utf8("_hoodie_commit_seqno"),
+        utf8("_hoodie_record_key"),
+        utf8("_hoodie_partition_path"),
+        utf8("_hoodie_file_name"),
+        Field::new("id", DataType::Utf8, false),
+        Field::new("kind", DataType::Utf8, false),
+        Field::new("ts", DataType::Int64, false),
+    ]));
+    let strings = |f: &dyn Fn(&(&str, &str, i64)) -> String| -> ArrayRef {
+        Arc::new(StringArray::from(rows.iter().map(f).collect::<Vec<_>>()))
+    };
+    let columns = vec![
+        strings(&|_| BASE_INSTANT.to_string()),
+        strings(&|r| format!("{BASE_INSTANT}_0_{}", r.0)),
+        strings(&|r| r.0.to_string()),
+        strings(&|_| String::new()),
+        strings(&|_| String::new()),
+        strings(&|r| r.0.to_string()),
+        strings(&|r| r.1.to_string()),
+        Arc::new(Int64Array::from(
+            rows.iter().map(|r| r.2).collect::<Vec<_>>(),
+        )) as ArrayRef,
+    ];
+    let batch = RecordBatch::try_new(schema.clone(), columns).unwrap();
+    let mut writer = ArrowWriter::try_new(fs::File::create(path).unwrap(), 
schema, None).unwrap();
+    writer.write(&batch).unwrap();
+    writer.close().unwrap();
+    fs::metadata(path).unwrap().len()
+}
+
+fn write_stat(path: &str, base_file: &str, size: u64) -> serde_json::Value {
+    serde_json::json!({
+        "fileId": FILE_ID, "path": path, "baseFile": base_file, "prevCommit": 
BASE_INSTANT,
+        "numWrites": 0, "numDeletes": 0, "numUpdateWrites": 0, 
"totalWriteBytes": size,
+        "fileSizeInBytes": size, "partitionPath": "", "totalLogBlocks": 0,
+    })
+}
+
+fn write_commit(root: &Path, file_name: &str, operation: &str, stat: 
serde_json::Value) {
+    let commit = serde_json::json!({
+        "partitionToWriteStats": { "": [stat] },
+        "compacted": false,
+        "extraMetadata": { "schema": AVRO_SCHEMA },
+        "operationType": operation,
+    });
+    fs::write(root.join(".hoodie").join(file_name), 
commit.to_string()).unwrap();
+}
+
+/// A version 6 merge-on-read table: base rows `k0` and `k1`, then one log 
block
+/// that updates `k1` and inserts `k2`, all carrying the enum column `kind`.
+fn create_table(root: &Path, block_version: u32) {
+    fs::create_dir_all(root.join(".hoodie")).unwrap();
+    let base_name = format!("{FILE_ID}_0-1-0_{BASE_INSTANT}.parquet");
+    let base_size = write_base_file(&root.join(&base_name), &[("k0", "A", 1), 
("k1", "B", 1)]);
+    write_commit(
+        root,
+        &format!("{BASE_INSTANT}.commit"),
+        "INSERT",
+        write_stat(&base_name, &base_name, base_size),
+    );
+
+    let log_name = format!(".{FILE_ID}_{BASE_INSTANT}.log.1_0-2-0");
+    let log_size = write_log_file(
+        &root.join(&log_name),
+        block_version,
+        &[("k1", "C", 2), ("k2", "A", 2)],
+    );
+    write_commit(
+        root,
+        &format!("{LOG_INSTANT}.deltacommit"),
+        "UPSERT",
+        write_stat(&log_name, &base_name, log_size as u64),
+    );
+
+    let props = [
+        "hoodie.table.name=log_block_compat",
+        "hoodie.table.type=MERGE_ON_READ",
+        "hoodie.table.version=6",
+        "hoodie.timeline.layout.version=1",
+        "hoodie.table.recordkey.fields=id",
+        "hoodie.table.precombine.field=ts",
+        "hoodie.record.merge.mode=COMMIT_TIME_ORDERING",
+        "hoodie.table.keygenerator.type=NON_PARTITION",
+        "hoodie.datasource.write.drop.partition.columns=false",
+    ];
+    fs::write(root.join(".hoodie/hoodie.properties"), 
props.join("\n")).unwrap();
+}
+
+async fn read_id_and_kind(root: &Path) -> Result<Vec<(String, String)>> {
+    let table = Table::new(root.to_str().unwrap()).await?;
+    let batches = table.read(&ReadOptions::new()).await?;
+    let batch = concat_batches(&batches[0].schema(), &batches)?;
+    assert_eq!(
+        batch.schema().field_with_name("kind")?.data_type(),
+        &DataType::Utf8
+    );
+    let column = |name| {
+        batch
+            .column_by_name(name)
+            .unwrap()
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap()
+            .clone()
+    };
+    let (ids, kinds) = (column("id"), column("kind"));
+    let mut rows: Vec<(String, String)> = (0..batch.num_rows())
+        .map(|i| (ids.value(i).to_string(), kinds.value(i).to_string()))
+        .collect();
+    rows.sort();
+    Ok(rows)
+}
+
+fn expected() -> Vec<(String, String)> {
+    [("k0", "A"), ("k1", "C"), ("k2", "A")]
+        .iter()
+        .map(|(id, kind)| (id.to_string(), kind.to_string()))
+        .collect()
+}
+
+/// A log update and a log insert on an enum column merge onto a base file that
+/// stores the column as a string.
+#[tokio::test]
+async fn test_mor_read_merges_enum_column_from_log_block() -> Result<()> {
+    let dir = tempfile::tempdir().unwrap();
+    create_table(dir.path(), 3);
+    assert_eq!(read_id_and_kind(dir.path()).await?, expected());
+    Ok(())
+}
+
+/// Writers before table version 6 stamp their Avro data blocks V2. A table
+/// upgraded from one keeps those blocks until compaction, and they merge the
+/// same as V3.
+#[tokio::test]
+async fn test_mor_read_merges_block_version_2_avro_data_block() -> Result<()> {
+    let dir = tempfile::tempdir().unwrap();
+    create_table(dir.path(), 2);
+    assert_eq!(read_id_and_kind(dir.path()).await?, expected());
+    Ok(())
+}

Reply via email to