/*
 * 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.
 */

#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <initializer_list>
#include <map>
#include <memory>
#include <numeric>
#include <optional>
#include <set>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

#include "arrow/api.h"
#include "arrow/array/array_base.h"
#include "arrow/array/builder_binary.h"
#include "arrow/array/builder_nested.h"
#include "arrow/array/builder_primitive.h"
#include "arrow/c/abi.h"
#include "arrow/c/bridge.h"
#include "arrow/ipc/json_simple.h"
#include "arrow/type.h"
#include "gtest/gtest.h"
#include "paimon/commit_context.h"
#include "paimon/common/data/binary_array_writer.h"
#include "paimon/common/data/binary_row.h"
#include "paimon/common/data/binary_row_writer.h"
#include "paimon/common/data/blob_defs.h"
#include "paimon/common/data/blob_descriptor.h"
#include "paimon/common/data/blob_utils.h"
#include "paimon/common/data/blob_view_struct.h"
#include "paimon/common/factories/io_hook.h"
#include "paimon/common/reader/reader_utils.h"
#include "paimon/common/table/special_fields.h"
#include "paimon/common/utils/path_util.h"
#include "paimon/common/utils/scope_guard.h"
#include "paimon/core/global_index/indexed_split_impl.h"
#include "paimon/core/schema/schema_manager.h"
#include "paimon/core/snapshot.h"
#include "paimon/core/stats/simple_stats.h"
#include "paimon/core/table/source/data_split_impl.h"
#include "paimon/core/utils/file_utils.h"
#include "paimon/core/utils/snapshot_manager.h"
#include "paimon/data/blob.h"
#include "paimon/defs.h"
#include "paimon/file_store_write.h"
#include "paimon/fs/file_system.h"
#include "paimon/fs/local/local_file_system.h"
#include "paimon/global_index/bitmap_global_index_result.h"
#include "paimon/memory/memory_pool.h"
#include "paimon/predicate/literal.h"
#include "paimon/predicate/predicate_builder.h"
#include "paimon/read_context.h"
#include "paimon/reader/batch_reader.h"
#include "paimon/record_batch.h"
#include "paimon/result.h"
#include "paimon/scan_context.h"
#include "paimon/status.h"
#include "paimon/table/source/data_split.h"
#include "paimon/table/source/startup_mode.h"
#include "paimon/table/source/table_read.h"
#include "paimon/testing/utils/io_exception_helper.h"
#include "paimon/testing/utils/test_helper.h"
#include "paimon/testing/utils/testharness.h"
#include "paimon/write_context.h"

namespace paimon {
class DataSplit;
class RecordBatch;
}  // namespace paimon

namespace paimon::test {

struct ReadResult {
    std::unique_ptr<BatchReader> batch_reader;
    std::shared_ptr<arrow::ChunkedArray> chunked_array;
};

class BlobTableInteTest : public testing::Test, public ::testing::WithParamInterface<std::string> {
 public:
    void SetUp() override {
        pool_ = GetDefaultPool();
        dir_ = UniqueTestDirectory::Create("local");
        blob_dir_ = UniqueTestDirectory::Create("local");
    }

    void TearDown() override {
        dir_.reset();
    }

    void CreateTable(const std::vector<std::string>& partition_keys,
                     const std::map<std::string, std::string>& options) const {
        CreateTable(fields_, partition_keys, options);
    }

    void CreateTable(const arrow::FieldVector& fields,
                     const std::vector<std::string>& partition_keys,
                     const std::map<std::string, std::string>& options) const {
        auto schema = arrow::schema(fields);
        ::ArrowSchema c_schema;
        ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());

        ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir_->Str(), {}));
        ASSERT_OK(catalog->CreateDatabase("foo", {}, /*ignore_if_exists=*/false));
        ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar"), &c_schema, partition_keys,
                                       /*primary_keys=*/{}, options,
                                       /*ignore_if_exists=*/false));
    }

    void CreateTable(const std::vector<std::string>& partition_keys) const {
        std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                      {Options::FILE_FORMAT, GetParam()},
                                                      {Options::FILE_SYSTEM, "local"},
                                                      {Options::ROW_TRACKING_ENABLED, "true"},
                                                      {Options::DATA_EVOLUTION_ENABLED, "true"}};
        return CreateTable(partition_keys, options);
    }

    void CreateTable() const {
        return CreateTable(/*partition_keys=*/{});
    }

    Result<std::vector<std::shared_ptr<CommitMessage>>> WriteArray(
        const std::string& table_path, const std::map<std::string, std::string>& partition,
        const std::vector<std::string>& write_cols,
        const std::vector<std::shared_ptr<arrow::Array>>& write_arrays) const {
        // write
        WriteContextBuilder write_builder(table_path, "commit_user_1");
        write_builder.WithWriteSchema(write_cols);
        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<WriteContext> write_context, write_builder.Finish());
        PAIMON_ASSIGN_OR_RAISE(auto file_store_write,
                               FileStoreWrite::Create(std::move(write_context)));

        for (const auto& write_array : write_arrays) {
            ArrowArray c_array;
            PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*write_array, &c_array));
            auto record_batch = std::make_unique<RecordBatch>(
                partition, /*bucket=*/0,
                /*row_kinds=*/std::vector<RecordBatch::RowKind>(), &c_array);
            PAIMON_RETURN_NOT_OK(file_store_write->Write(std::move(record_batch)));
        }
        PAIMON_ASSIGN_OR_RAISE(auto commit_msgs,
                               file_store_write->PrepareCommit(
                                   /*wait_compaction=*/false, /*commit_identifier=*/0));
        PAIMON_RETURN_NOT_OK(file_store_write->Close());
        return commit_msgs;
    }

    void SetFirstRowId(int64_t reset_first_row_id,
                       std::vector<std::shared_ptr<CommitMessage>>& commit_msgs) const {
        for (auto& commit_msg : commit_msgs) {
            auto commit_msg_impl = std::dynamic_pointer_cast<CommitMessageImpl>(commit_msg);
            ASSERT_TRUE(commit_msg_impl);
            for (auto& file : commit_msg_impl->data_increment_.new_files_) {
                file->AssignFirstRowId(reset_first_row_id);
            }
        }
    }

    Status Commit(const std::string& table_path,
                  const std::vector<std::shared_ptr<CommitMessage>>& commit_msgs) const {
        // commit
        CommitContextBuilder commit_builder(table_path, "commit_user_1");
        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<CommitContext> commit_context,
                               commit_builder.Finish());
        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FileStoreCommit> file_store_commit,
                               FileStoreCommit::Create(std::move(commit_context)));
        return file_store_commit->Commit(commit_msgs);
    }

    Status WriteNextSchema(const std::string& table_path, const std::vector<DataField>& fields,
                           int32_t highest_field_id,
                           const std::map<std::string, std::string>& options) const {
        SchemaManager schema_manager(dir_->GetFileSystem(), table_path);
        PAIMON_ASSIGN_OR_RAISE(auto latest_schema_opt, schema_manager.Latest());
        if (!latest_schema_opt) {
            return Status::Invalid("table schema does not exist");
        }
        auto next_schema = std::make_shared<TableSchema>(*latest_schema_opt.value());
        next_schema->id_ = latest_schema_opt.value()->Id() + 1;
        next_schema->fields_ = fields;
        next_schema->highest_field_id_ = highest_field_id;
        next_schema->options_ = options;
        PAIMON_ASSIGN_OR_RAISE(std::string schema_content, next_schema->ToJsonString());
        std::string schema_path = PathUtil::JoinPath(schema_manager.SchemaDirectory(),
                                                     "schema-" + std::to_string(next_schema->Id()));
        return dir_->GetFileSystem()->AtomicStore(schema_path, schema_content);
    }

    /// Scan table and return the plan (without reading data).
    Result<std::shared_ptr<Plan>> ScanTable(const std::string& table_path,
                                            const std::shared_ptr<Predicate>& predicate = nullptr,
                                            const std::vector<Range>& row_ranges = {}) const {
        ScanContextBuilder scan_context_builder(table_path);
        scan_context_builder.SetPredicate(predicate);
        if (!row_ranges.empty()) {
            auto global_index_result = BitmapGlobalIndexResult::FromRanges(row_ranges);
            scan_context_builder.SetGlobalIndexResult(global_index_result);
        }
        PAIMON_ASSIGN_OR_RAISE(auto scan_context, scan_context_builder.Finish());
        PAIMON_ASSIGN_OR_RAISE(auto table_scan, TableScan::Create(std::move(scan_context)));
        PAIMON_ASSIGN_OR_RAISE(auto result_plan, table_scan->CreatePlan());
        return result_plan;
    }

    /// Read from table using a pre-scanned plan, returning the ChunkedArray and batch_reader.
    /// The batch_reader must outlive the returned ChunkedArray (array memory depends on reader).
    Result<ReadResult> ReadTable(const std::string& table_path,
                                 const std::vector<std::string>& read_schema,
                                 const std::shared_ptr<Plan>& plan,
                                 const std::shared_ptr<Predicate>& predicate = nullptr,
                                 const std::map<std::string, std::string>& options = {}) const {
        auto splits = plan->Splits();
        ReadContextBuilder read_context_builder(table_path);
        read_context_builder.SetReadFieldNames(read_schema).SetPredicate(predicate);
        if (!options.empty()) {
            read_context_builder.SetOptions(options);
        }
        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ReadContext> read_context,
                               read_context_builder.Finish());
        PAIMON_ASSIGN_OR_RAISE(auto table_read, TableRead::Create(std::move(read_context)));
        PAIMON_ASSIGN_OR_RAISE(auto batch_reader, table_read->CreateReader(splits));
        PAIMON_ASSIGN_OR_RAISE(auto read_result,
                               ReadResultCollector::CollectResult(batch_reader.get()));
        return ReadResult{std::move(batch_reader), std::move(read_result)};
    }

    /// Convenience: scan + read in one call.
    Result<ReadResult> ScanAndReadResult(const std::string& table_path,
                                         const std::vector<std::string>& read_schema,
                                         const std::shared_ptr<Predicate>& predicate = nullptr,
                                         const std::vector<Range>& row_ranges = {}) const {
        PAIMON_ASSIGN_OR_RAISE(auto result_plan, ScanTable(table_path, predicate, row_ranges));
        return ReadTable(table_path, read_schema, result_plan, predicate);
    }

    /// Prepend a _VALUE_KIND (Insert) column to a StructArray.
    static Result<std::shared_ptr<arrow::StructArray>> PrependRowKindColumn(
        const std::shared_ptr<arrow::StructArray>& array) {
        auto row_kind_scalar =
            std::make_shared<arrow::Int8Scalar>(RowKind::Insert()->ToByteValue());
        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
            auto row_kind_array, arrow::MakeArrayFromScalar(*row_kind_scalar, array->length()));
        arrow::ArrayVector fields_with_row_kind = array->fields();
        std::vector<std::string> names_with_row_kind =
            arrow::schema(array->type()->fields())->field_names();
        fields_with_row_kind.insert(fields_with_row_kind.begin(), row_kind_array);
        names_with_row_kind.insert(names_with_row_kind.begin(), "_VALUE_KIND");
        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
            auto result, arrow::StructArray::Make(fields_with_row_kind, names_with_row_kind));
        return std::dynamic_pointer_cast<arrow::StructArray>(result);
    }

    Status ScanAndRead(const std::string& table_path, const std::vector<std::string>& read_schema,
                       const std::shared_ptr<arrow::StructArray>& expected_array,
                       const std::shared_ptr<Predicate>& predicate = nullptr,
                       const std::vector<Range>& row_ranges = {}) const {
        PAIMON_ASSIGN_OR_RAISE(auto scan_read,
                               ScanAndReadResult(table_path, read_schema, predicate, row_ranges));

        if (!expected_array) {
            EXPECT_FALSE(scan_read.chunked_array);
            return Status::OK();
        }
        PAIMON_ASSIGN_OR_RAISE(auto expected_with_row_kind, PrependRowKindColumn(expected_array));
        auto expected_chunk_array = std::make_shared<arrow::ChunkedArray>(expected_with_row_kind);
        EXPECT_TRUE(expected_chunk_array->Equals(scan_read.chunked_array))
            << "result:" << scan_read.chunked_array->ToString() << std::endl
            << "expected:" << expected_chunk_array->ToString();
        return Status::OK();
    }

    std::shared_ptr<arrow::StructArray> PrepareBulkData(
        int32_t write_batch_size, std::function<std::string(int32_t)> data_generator,
        const arrow::FieldVector& fields) const {
        std::string data_str = "[";
        for (int32_t i = 0; i < write_batch_size; i++) {
            data_str.append("[");
            auto row_str = data_generator(i);
            data_str.append(row_str);
            data_str.append("],");
        }
        data_str.pop_back();
        data_str.append("]");
        return std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), data_str)
                .ValueOrDie());
    }

    /// Convert a StructArray with raw blob bytes into a StructArray with serialized
    /// BlobDescriptor bytes. Each raw blob value is written to a temporary file, and
    /// the corresponding cell is replaced with the serialized BlobDescriptor pointing
    /// to that file.
    /// Common framework for transforming blob fields in a StructArray.
    /// Non-blob fields are kept as-is; blob fields are processed row-by-row via `transform_row`.
    /// `transform_row` receives (binary_value_view) and returns the transformed bytes via builder.
    using BlobRowTransform =
        std::function<Status(const std::string_view& value, arrow::LargeBinaryBuilder* builder)>;

    Result<std::shared_ptr<arrow::StructArray>> TransformBlobFields(
        const std::shared_ptr<arrow::StructArray>& input_array,
        const std::set<std::string>& blob_fields, BlobRowTransform transform_row) const {
        auto fields = input_array->type()->fields();
        arrow::ArrayVector child_arrays;

        for (const auto& field : fields) {
            auto col = input_array->GetFieldByName(field->name());
            if (blob_fields.count(field->name()) == 0) {
                child_arrays.push_back(col);
                continue;
            }
            const auto& binary_array =
                arrow::internal::checked_cast<const arrow::LargeBinaryArray&>(*col);
            arrow::LargeBinaryBuilder builder;
            for (int64_t i = 0; i < binary_array.length(); ++i) {
                if (binary_array.IsNull(i)) {
                    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.AppendNull());
                    continue;
                }
                PAIMON_RETURN_NOT_OK(transform_row(binary_array.GetView(i), &builder));
            }
            std::shared_ptr<arrow::Array> result_col;
            PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&result_col));
            child_arrays.push_back(result_col);
        }

        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(auto result,
                                          arrow::StructArray::Make(child_arrays, fields));
        return result;
    }

    Result<std::shared_ptr<arrow::StructArray>> ConvertRawBlobToDescriptor(
        const std::shared_ptr<arrow::StructArray>& raw_array,
        const std::set<std::string>& blob_fields) {
        auto fs = std::make_shared<LocalFileSystem>();
        return TransformBlobFields(
            raw_array, blob_fields,
            [&](const std::string_view& raw_value, arrow::LargeBinaryBuilder* builder) -> Status {
                std::string file_path =
                    blob_dir_->Str() + "/blob_" + std::to_string(blob_file_counter_++) + ".bin";
                auto raw_size = static_cast<int64_t>(raw_value.size());
                PAIMON_ASSIGN_OR_RAISE(auto out, fs->Create(file_path, /*overwrite=*/true));
                PAIMON_ASSIGN_OR_RAISE(auto written, out->Write(raw_value.data(), raw_size));
                PAIMON_RETURN_NOT_OK(out->Flush());
                PAIMON_RETURN_NOT_OK(out->Close());
                if (written != raw_size) {
                    return Status::Invalid("Short write: expected {}, wrote {}", raw_value.size(),
                                           written);
                }
                PAIMON_ASSIGN_OR_RAISE(auto blob, Blob::FromPath(file_path));
                auto descriptor = blob->ToDescriptor(pool_);
                PAIMON_RETURN_NOT_OK_FROM_ARROW(
                    builder->Append(descriptor->data(), descriptor->size()));
                return Status::OK();
            });
    }

    /// Delete the file referenced by the serialized BlobDescriptor at `row` of blob field
    /// `field_name`, so that a subsequent table write observes a missing file.
    Status DeleteDescriptorTarget(const std::shared_ptr<arrow::StructArray>& desc_array,
                                  const std::string& field_name, int64_t row) const {
        const auto& blob_col = arrow::internal::checked_cast<const arrow::LargeBinaryArray&>(
            *desc_array->GetFieldByName(field_name));
        std::string_view descriptor_bytes = blob_col.GetView(row);
        PAIMON_ASSIGN_OR_RAISE(
            std::unique_ptr<BlobDescriptor> descriptor,
            BlobDescriptor::Deserialize(descriptor_bytes.data(), descriptor_bytes.size()));
        LocalFileSystem fs;
        return fs.Delete(descriptor->Uri(), /*recursive=*/false);
    }

    /// Rewrite the serialized BlobDescriptor at `row` of blob field `field_name` to reference
    /// an offset beyond the end of the target file, so that a subsequent table write observes
    /// a fetch failure that is not a missing file. `row` counts non-null rows.
    Result<std::shared_ptr<arrow::StructArray>> CorruptDescriptorOffset(
        const std::shared_ptr<arrow::StructArray>& desc_array, const std::string& field_name,
        int64_t row) const {
        int64_t current_row = 0;
        return TransformBlobFields(
            desc_array, {field_name},
            [&](const std::string_view& descriptor_bytes,
                arrow::LargeBinaryBuilder* builder) -> Status {
                if (current_row++ != row) {
                    PAIMON_RETURN_NOT_OK_FROM_ARROW(
                        builder->Append(descriptor_bytes.data(), descriptor_bytes.size()));
                    return Status::OK();
                }
                PAIMON_ASSIGN_OR_RAISE(
                    std::unique_ptr<BlobDescriptor> descriptor,
                    BlobDescriptor::Deserialize(descriptor_bytes.data(), descriptor_bytes.size()));
                PAIMON_ASSIGN_OR_RAISE(
                    std::unique_ptr<BlobDescriptor> corrupted,
                    BlobDescriptor::Create(descriptor->Version(), descriptor->Uri(),
                                           /*offset=*/1 << 20, descriptor->Length()));
                auto corrupted_bytes = corrupted->Serialize(pool_);
                PAIMON_RETURN_NOT_OK_FROM_ARROW(
                    builder->Append(corrupted_bytes->data(), corrupted_bytes->size()));
                return Status::OK();
            });
    }

    struct BlobDescriptorPathRewrite {
        std::string table_path;
        std::vector<std::string> table_relative_blob_dirs;
    };

    static std::optional<std::string> TryRewriteDescriptorUri(
        const std::string& descriptor_uri, const BlobDescriptorPathRewrite& rewrite,
        const std::shared_ptr<LocalFileSystem>& fs) {
        if (rewrite.table_path.empty()) {
            return std::nullopt;
        }

        for (const auto& blob_dir : rewrite.table_relative_blob_dirs) {
            const std::string marker = "/" + blob_dir + "/";
            auto marker_pos = descriptor_uri.find(marker);
            if (marker_pos != std::string::npos) {
                std::string relative_blob_path = descriptor_uri.substr(marker_pos + 1);
                return PathUtil::JoinPath(rewrite.table_path, relative_blob_path);
            }
        }
        return std::nullopt;
    }

    /// Convert a StructArray with serialized BlobDescriptor bytes back to a StructArray
    /// with raw blob bytes. Only blob fields are resolved; other columns (including
    /// _VALUE_KIND) are kept as-is.
    Result<std::shared_ptr<arrow::StructArray>> ConvertDescriptorToRawBlob(
        const std::shared_ptr<arrow::StructArray>& desc_array,
        const std::set<std::string>& blob_fields,
        const BlobDescriptorPathRewrite& rewrite = {}) const {
        auto fs = std::make_shared<LocalFileSystem>();
        return TransformBlobFields(
            desc_array, blob_fields,
            [&](const std::string_view& descriptor_bytes,
                arrow::LargeBinaryBuilder* builder) -> Status {
                PAIMON_ASSIGN_OR_RAISE(
                    auto descriptor,
                    BlobDescriptor::Deserialize(descriptor_bytes.data(), descriptor_bytes.size()));
                std::string descriptor_uri = descriptor->Uri();
                auto rewritten_uri = TryRewriteDescriptorUri(descriptor_uri, rewrite, fs);
                if (rewritten_uri.has_value()) {
                    descriptor_uri = rewritten_uri.value();
                }

                PAIMON_ASSIGN_OR_RAISE(
                    auto rewritten_descriptor,
                    BlobDescriptor::Create(descriptor->Version(), descriptor_uri,
                                           descriptor->Offset(), descriptor->Length()));
                auto rewritten_descriptor_bytes = rewritten_descriptor->Serialize(pool_);
                PAIMON_ASSIGN_OR_RAISE(auto blob,
                                       Blob::FromDescriptor(rewritten_descriptor_bytes->data(),
                                                            rewritten_descriptor_bytes->size()));
                PAIMON_ASSIGN_OR_RAISE(auto data, blob->ToData(fs, pool_));
                PAIMON_RETURN_NOT_OK_FROM_ARROW(builder->Append(data->data(), data->size()));
                return Status::OK();
            });
    }

    /// Verify DataFileMeta properties from a scan plan.
    /// Each vector element corresponds to one expected DataFileMeta (ordered by file index).
    static void VerifyDataFileMetas(
        const std::shared_ptr<Plan>& plan, size_t expected_file_count,
        const std::vector<int64_t>& expected_row_counts,
        const std::vector<int64_t>& expected_min_seqs,
        const std::vector<int64_t>& expected_max_seqs,
        const std::vector<int64_t>& expected_first_row_ids,
        const std::vector<std::optional<std::vector<std::string>>>& expected_write_cols) {
        std::vector<std::shared_ptr<DataFileMeta>> all_files;
        for (const auto& split : plan->Splits()) {
            auto data_split = std::dynamic_pointer_cast<DataSplitImpl>(split);
            ASSERT_TRUE(data_split);
            for (const auto& file : data_split->DataFiles()) {
                all_files.push_back(file);
            }
        }
        ASSERT_EQ(all_files.size(), expected_file_count);
        ASSERT_EQ(expected_row_counts.size(), expected_file_count);
        ASSERT_EQ(expected_min_seqs.size(), expected_file_count);
        ASSERT_EQ(expected_max_seqs.size(), expected_file_count);
        ASSERT_EQ(expected_first_row_ids.size(), expected_file_count);
        ASSERT_EQ(expected_write_cols.size(), expected_file_count);
        for (size_t i = 0; i < all_files.size(); ++i) {
            const auto& file = all_files[i];
            EXPECT_EQ(file->row_count, expected_row_counts[i]);
            EXPECT_EQ(file->min_sequence_number, expected_min_seqs[i]);
            EXPECT_EQ(file->max_sequence_number, expected_max_seqs[i]);
            ASSERT_TRUE(file->first_row_id.has_value());
            EXPECT_EQ(file->first_row_id.value(), expected_first_row_ids[i]);
            EXPECT_EQ(file->write_cols, expected_write_cols[i]);
        }
    }

 private:
    std::shared_ptr<MemoryPool> pool_;
    std::unique_ptr<UniqueTestDirectory> dir_;
    std::unique_ptr<UniqueTestDirectory> blob_dir_;
    int blob_file_counter_ = 0;
    arrow::FieldVector fields_ = {arrow::field("f0", arrow::int32()), BlobUtils::ToArrowField("f1"),
                                  arrow::field("f2", arrow::utf8())};
};

std::vector<std::string> GetTestValuesForBlobTableInteTest() {
    std::vector<std::string> values;
    values.emplace_back("parquet");
#ifdef PAIMON_ENABLE_ORC
    values.emplace_back("orc");
#endif
#ifdef PAIMON_ENABLE_AVRO
    values.emplace_back("avro");
#endif
    return values;
}

INSTANTIATE_TEST_SUITE_P(FileFormat, BlobTableInteTest,
                         ::testing::ValuesIn(GetTestValuesForBlobTableInteTest()));

TEST_P(BlobTableInteTest, TestAppendTableWriteWithBlobAsDescriptorTrue) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::utf8()),
                                 arrow::field("f1", arrow::int32()),
                                 BlobUtils::ToArrowField("blob", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},       {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},      {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"}, {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_AS_DESCRIPTOR, "true"},   {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // prepare data: input uses plain raw blob bytes for readability
    std::string raw_json = R"([
        ["str_0", null, "hello_blob_0"],
        ["str_1", 1,    "blob_data_1"],
        ["str_2", 2,    "blob_data_2"],
        ["str_3", null, "blob_data_3"]
    ])";
    auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array, ConvertRawBlobToDescriptor(raw_array, {"blob"}));

    // write descriptor array
    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {desc_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // read result contains descriptors pointing to paimon internal blob files
    // resolve descriptors back to raw bytes, then prepend _VALUE_KIND and compare
    ASSERT_OK_AND_ASSIGN(auto result, ScanAndReadResult(table_path, schema->field_names()));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(read_struct, {"blob"}));
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(raw_array));
    ASSERT_TRUE(resolved->Equals(expected_with_rk));
}

TEST_P(BlobTableInteTest, TestAppendTableWriteWithBlobAsDescriptorFalse) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::utf8()),
                                 arrow::field("f1", arrow::int32()),
                                 BlobUtils::ToArrowField("blob", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},       {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},      {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"}, {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_AS_DESCRIPTOR, "false"},  {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    std::string data_json = R"([
        ["str_0", null, "apple"],
        ["str_1", 1,    "banana"],
        ["str_2", 2,    "cat"],
        ["str_3", null, "dog"]
    ])";
    auto write_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), data_json).ValueOrDie());

    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {write_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // BLOB_AS_DESCRIPTOR=false: blob data is stored inline, read result should match input
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), write_array));
}

TEST_P(BlobTableInteTest, TestWriteNullOnMissingFile) {
    // blob-write-null-on-missing-file=true: a descriptor whose file is gone at write time
    // becomes a NULL blob element, while the row itself is still written and merged with
    // the data file on read.
    arrow::FieldVector fields = {arrow::field("f0", arrow::utf8()),
                                 arrow::field("f1", arrow::int32()),
                                 BlobUtils::ToArrowField("blob", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},       {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},      {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"}, {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_AS_DESCRIPTOR, "true"},   {Options::BLOB_WRITE_NULL_ON_MISSING_FILE, "true"},
        {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    std::string raw_json = R"([
        ["str_0", 0, "blob_data_0"],
        ["str_1", 1, "blob_data_1"],
        ["str_2", 2, "blob_data_2"]
    ])";
    auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array, ConvertRawBlobToDescriptor(raw_array, {"blob"}));

    // Remove the file behind row 1's descriptor so the write observes a missing file.
    ASSERT_OK(DeleteDescriptorTarget(desc_array, "blob", /*row=*/1));

    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {desc_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // Both the main data file and the .blob file keep the full row count; the read path
    // merges them back into rows where row 1's blob is NULL.
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    VerifyDataFileMetas(plan, /*expected_file_count=*/2, /*expected_row_counts=*/{3, 3},
                        /*expected_min_seqs=*/{1, 1}, /*expected_max_seqs=*/{1, 1},
                        /*expected_first_row_ids=*/{0, 0},
                        /*expected_write_cols=*/
                        {std::vector<std::string>{"f0", "f1"}, std::vector<std::string>{"blob"}});

    ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, schema->field_names(), plan));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(read_struct, {"blob"}));

    std::string expected_json = R"([
        ["str_0", 0, "blob_data_0"],
        ["str_1", 1, null],
        ["str_2", 2, "blob_data_2"]
    ])";
    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_array));
    ASSERT_TRUE(resolved->Equals(expected_with_rk))
        << "result:" << resolved->ToString() << std::endl
        << "expected:" << expected_with_rk->ToString();
}

TEST_P(BlobTableInteTest, TestMissingFileFailsWriteWhenWriteNullDisabled) {
    // Without blob-write-null-on-missing-file, a missing descriptor file fails the write.
    arrow::FieldVector fields = {arrow::field("f0", arrow::utf8()),
                                 arrow::field("f1", arrow::int32()),
                                 BlobUtils::ToArrowField("blob", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},       {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},      {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"}, {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_AS_DESCRIPTOR, "true"},   {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    std::string raw_json = R"([
        ["str_0", 0, "blob_data_0"],
        ["str_1", 1, "blob_data_1"]
    ])";
    auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array, ConvertRawBlobToDescriptor(raw_array, {"blob"}));
    ASSERT_OK(DeleteDescriptorTarget(desc_array, "blob", /*row=*/1));

    auto schema = arrow::schema(fields);
    ASSERT_NOK_WITH_MSG(WriteArray(table_path, {}, schema->field_names(), {desc_array}),
                        "not exists");
}

TEST_P(BlobTableInteTest, TestWriteNullOnFetchFailure) {
    // blob-write-null-on-fetch-failure=true: a descriptor whose data cannot be fetched for a
    // reason other than a missing file (here: offset beyond the end of the file) becomes a
    // NULL blob element, while the row itself is still written and merged with the data file
    // on read.
    arrow::FieldVector fields = {arrow::field("f0", arrow::utf8()),
                                 arrow::field("f1", arrow::int32()),
                                 BlobUtils::ToArrowField("blob", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_AS_DESCRIPTOR, "true"},
        {Options::BLOB_WRITE_NULL_ON_FETCH_FAILURE, "true"},
        {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    std::string raw_json = R"([
        ["str_0", 0, "blob_data_0"],
        ["str_1", 1, "blob_data_1"],
        ["str_2", 2, "blob_data_2"]
    ])";
    auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array, ConvertRawBlobToDescriptor(raw_array, {"blob"}));

    // Point row 1's descriptor at an offset beyond the end of its file so the write observes
    // a fetch failure that is not a missing file.
    ASSERT_OK_AND_ASSIGN(desc_array, CorruptDescriptorOffset(desc_array, "blob", /*row=*/1));

    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {desc_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // Both the main data file and the .blob file keep the full row count; the read path
    // merges them back into rows where row 1's blob is NULL.
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    VerifyDataFileMetas(plan, /*expected_file_count=*/2, /*expected_row_counts=*/{3, 3},
                        /*expected_min_seqs=*/{1, 1}, /*expected_max_seqs=*/{1, 1},
                        /*expected_first_row_ids=*/{0, 0},
                        /*expected_write_cols=*/
                        {std::vector<std::string>{"f0", "f1"}, std::vector<std::string>{"blob"}});

    ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, schema->field_names(), plan));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(read_struct, {"blob"}));

    std::string expected_json = R"([
        ["str_0", 0, "blob_data_0"],
        ["str_1", 1, null],
        ["str_2", 2, "blob_data_2"]
    ])";
    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_array));
    ASSERT_TRUE(resolved->Equals(expected_with_rk))
        << "result:" << resolved->ToString() << std::endl
        << "expected:" << expected_with_rk->ToString();
}

TEST_P(BlobTableInteTest, TestWriteNullOnFetchFailureCoversMissingFile) {
    // The existence check runs only under blob-write-null-on-missing-file, so with only
    // blob-write-null-on-fetch-failure a missing file is converted as an ordinary fetch failure.
    arrow::FieldVector fields = {arrow::field("f0", arrow::utf8()),
                                 arrow::field("f1", arrow::int32()),
                                 BlobUtils::ToArrowField("blob", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_AS_DESCRIPTOR, "true"},
        {Options::BLOB_WRITE_NULL_ON_FETCH_FAILURE, "true"},
        {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    std::string raw_json = R"([
        ["str_0", 0, "blob_data_0"],
        ["str_1", 1, "blob_data_1"],
        ["str_2", 2, "blob_data_2"]
    ])";
    auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array, ConvertRawBlobToDescriptor(raw_array, {"blob"}));
    ASSERT_OK(DeleteDescriptorTarget(desc_array, "blob", /*row=*/1));

    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {desc_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    VerifyDataFileMetas(plan, /*expected_file_count=*/2, /*expected_row_counts=*/{3, 3},
                        /*expected_min_seqs=*/{1, 1}, /*expected_max_seqs=*/{1, 1},
                        /*expected_first_row_ids=*/{0, 0},
                        /*expected_write_cols=*/
                        {std::vector<std::string>{"f0", "f1"}, std::vector<std::string>{"blob"}});

    ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, schema->field_names(), plan));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(read_struct, {"blob"}));

    std::string expected_json = R"([
        ["str_0", 0, "blob_data_0"],
        ["str_1", 1, null],
        ["str_2", 2, "blob_data_2"]
    ])";
    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_array));
    ASSERT_TRUE(resolved->Equals(expected_with_rk))
        << "result:" << resolved->ToString() << std::endl
        << "expected:" << expected_with_rk->ToString();
}

TEST_P(BlobTableInteTest, TestBasic) {
    CreateTable();
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    // write field: f0, f1, f2
    std::vector<std::string> write_cols0 = schema->field_names();
    auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "a", "b"]
    ])")
            .ValueOrDie());

    ASSERT_OK_AND_ASSIGN(auto commit_msgs, WriteArray(table_path, {}, write_cols0, {src_array0}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // write field: f1, f2
    std::vector<std::string> write_cols1 = {"f1", "f2"};
    auto src_array1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[1], fields_[2]}), R"([
        ["new_blob", "c"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(commit_msgs, WriteArray(table_path, {}, write_cols1, {src_array1}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs);
    ASSERT_OK(Commit(table_path, commit_msgs));
    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "new_blob", "c"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));

    // read with row tracking
    auto expected_row_tracking_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(
            arrow::struct_({fields_[1], fields_[0], SpecialFields::SequenceNumber().field_,
                            SpecialFields::RowId().field_, fields_[2]}),
            R"([
        ["new_blob", 1, 2, 0, "c"]
    ])")
            .ValueOrDie());

    ASSERT_OK(ScanAndRead(table_path, {"f1", "f0", "_SEQUENCE_NUMBER", "_ROW_ID", "f2"},
                          expected_row_tracking_array));
}

TEST_P(BlobTableInteTest, TestBlobFilesAcrossSchemaIds) {
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(/*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // Simulate the post-compaction layout: one normal file bridges blob files across schema ids.
    std::vector<std::string> write_cols0 = {"f0", "f2"};
    auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[0], fields_[2]}), R"([
        [1, "a"],
        [2, "b"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0, WriteArray(table_path, {}, write_cols0, {src_array0}));
    ASSERT_OK(Commit(table_path, commit_msgs0));

    std::vector<std::string> blob_write_cols = {"f1"};
    auto src_array1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[1]}), R"([
        ["c"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1,
                         WriteArray(table_path, {}, blob_write_cols, {src_array1}));
    SetFirstRowId(0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    auto f3 = arrow::field("f3", arrow::int64());
    ASSERT_OK(WriteNextSchema(table_path,
                              {DataField(0, fields_[0]), DataField(1, fields_[1]),
                               DataField(2, fields_[2]), DataField(3, f3)},
                              /*highest_field_id=*/3, options));

    auto src_array2 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[1]}), R"([
        ["d"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs2,
                         WriteArray(table_path, {}, blob_write_cols, {src_array2}));
    SetFirstRowId(1, commit_msgs2);
    ASSERT_OK(Commit(table_path, commit_msgs2));

    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "c", "a"],
        [2, "d", "b"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, arrow::schema(fields_)->field_names(), expected_array));
}

TEST_P(BlobTableInteTest, TestMultipleAppends) {
    CreateTable();
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    // write field: f0, f1, f2
    std::vector<std::string> write_cols0 = schema->field_names();
    auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs, WriteArray(table_path, {}, write_cols0, {src_array0}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // write field: f0, f1
    std::vector<std::string> write_cols1 = {"f0", "f1"};
    auto src_array1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[0], fields_[1]}), R"([
        [1, "a"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, write_cols1, {src_array1}));
    SetFirstRowId(10, commit_msgs1);

    // write field: f2
    std::vector<std::string> write_cols2 = {"f2"};
    auto src_array2 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[2]}), R"([
        ["b"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs2, WriteArray(table_path, {}, write_cols2, {src_array2}));
    SetFirstRowId(10, commit_msgs2);

    std::vector<std::shared_ptr<CommitMessage>> total_msgs;
    total_msgs.insert(total_msgs.end(), commit_msgs1.begin(), commit_msgs1.end());
    total_msgs.insert(total_msgs.end(), commit_msgs2.begin(), commit_msgs2.end());
    ASSERT_OK(Commit(table_path, total_msgs));

    // write field: f0, f1
    std::vector<std::string> write_cols3 = {"f0", "f1"};
    auto src_array3 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[0], fields_[1]}), R"([
        [2, "c"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs3, WriteArray(table_path, {}, write_cols3, {src_array3}));
    SetFirstRowId(11, commit_msgs3);
    ASSERT_OK(Commit(table_path, commit_msgs3));

    // write field: f2
    std::vector<std::string> write_cols4 = {"f2"};
    auto src_array4 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[2]}), R"([
        ["d"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs4, WriteArray(table_path, {}, write_cols4, {src_array4}));
    SetFirstRowId(11, commit_msgs4);
    ASSERT_OK(Commit(table_path, commit_msgs4));

    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [1, "a", "b"],
        [2, "c", "d"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));

    // read with row tracking
    auto expected_row_tracking_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({
                                                      fields_[0],
                                                      fields_[1],
                                                      fields_[2],
                                                      SpecialFields::RowId().field_,
                                                      SpecialFields::SequenceNumber().field_,
                                                  }),
                                                  R"([
        [1, "a", "b", 0, 1],
        [1, "a", "b", 1, 1],
        [1, "a", "b", 2, 1],
        [1, "a", "b", 3, 1],
        [1, "a", "b", 4, 1],
        [1, "a", "b", 5, 1],
        [1, "a", "b", 6, 1],
        [1, "a", "b", 7, 1],
        [1, "a", "b", 8, 1],
        [1, "a", "b", 9, 1],
        [1, "a", "b", 10, 2],
        [2, "c", "d", 11, 4]
    ])")
            .ValueOrDie());

    ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2", "_ROW_ID", "_SEQUENCE_NUMBER"},
                          expected_row_tracking_array));
}

TEST_P(BlobTableInteTest, TestDataEvolutionBlobOnlyWriteWithFirstRowId) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 arrow::field("f1", arrow::utf8()), BlobUtils::ToArrowField("b0")};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields);

    // Initial full-row write assigns row ids 0 and 1.
    auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [1, "a", "old_blob_0"],
        [2, "b", "old_blob_1"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0,
                         WriteArray(table_path, {}, schema->field_names(), {src_array0}));
    ASSERT_OK(Commit(table_path, commit_msgs0));

    // Update only b0 and align it with the existing row ids.
    std::vector<std::string> blob_write_cols = {"b0"};
    auto src_array1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[2]}), R"([
        ["new_blob_0"],
        ["new_blob_1"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1,
                         WriteArray(table_path, {}, blob_write_cols, {src_array1}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [1, "a", "new_blob_0"],
        [2, "b", "new_blob_1"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));

    auto expected_with_row_id = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(
            arrow::struct_({fields[0], fields[1], fields[2], SpecialFields::RowId().field_}),
            R"([
        [1, "a", "new_blob_0", 0],
        [2, "b", "new_blob_1", 1]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "b0", "_ROW_ID"}, expected_with_row_id));
}

/// Build a single-blob-column StructArray for a data-evolution partial update: "PH" marks a row
/// whose blob is not updated (persisted as a placeholder entry), std::nullopt a null blob.
std::shared_ptr<arrow::StructArray> MakeBlobUpdateArray(
    const std::shared_ptr<arrow::Field>& blob_field,
    const std::vector<std::optional<std::string>>& rows) {
    auto struct_type = arrow::struct_({blob_field});
    arrow::StructBuilder struct_builder(struct_type, arrow::default_memory_pool(),
                                        {std::make_shared<arrow::LargeBinaryBuilder>()});
    auto blob_builder = static_cast<arrow::LargeBinaryBuilder*>(struct_builder.field_builder(0));
    for (const auto& row : rows) {
        EXPECT_TRUE(struct_builder.Append().ok());
        if (!row) {
            EXPECT_TRUE(blob_builder->AppendNull().ok());
        } else if (*row == "PH") {
            std::string_view sentinel = BlobDefs::PlaceholderSentinelView();
            EXPECT_TRUE(blob_builder->Append(sentinel.data(), sentinel.size()).ok());
        } else {
            EXPECT_TRUE(blob_builder->Append(row->data(), row->size()).ok());
        }
    }
    std::shared_ptr<arrow::Array> array;
    EXPECT_TRUE(struct_builder.Finish(&array).ok());
    return std::dynamic_pointer_cast<arrow::StructArray>(array);
}

TEST_P(BlobTableInteTest, TestDataEvolutionBlobPartialUpdateFallback) {
    // the blob column is updated to null below, so it must be nullable
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 arrow::field("f1", arrow::utf8()),
                                 BlobUtils::ToArrowField("b0", /*nullable=*/true)};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields);

    // Initial full-row write assigns row ids 0-2.
    auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [1, "a", "blob_a"],
        [2, "b", "blob_b"],
        [3, "c", "blob_c"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0,
                         WriteArray(table_path, {}, schema->field_names(), {src_array0}));
    ASSERT_OK(Commit(table_path, commit_msgs0));

    // Partial update: only row 1 gets a new blob, the untouched rows are written as
    // placeholder entries and must fall back to the previous blob file when read.
    auto update_array = MakeBlobUpdateArray(fields[2], {"PH", "updated_b", "PH"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, {"b0"}, {update_array}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [1, "a", "blob_a"],
        [2, "b", "updated_b"],
        [3, "c", "blob_c"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));

    // updating a blob to null is not a placeholder: the null must win over older layers
    auto null_update_array = MakeBlobUpdateArray(fields[2], {std::nullopt, "PH", "PH"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs2,
                         WriteArray(table_path, {}, {"b0"}, {null_update_array}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs2);
    ASSERT_OK(Commit(table_path, commit_msgs2));

    auto expected_array2 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [1, "a", null],
        [2, "b", "updated_b"],
        [3, "c", "blob_c"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array2));

    // row ids still come from the data files and stay aligned with the fallback result
    auto expected_with_row_id = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(
            arrow::struct_({fields[0], fields[1], fields[2], SpecialFields::RowId().field_}),
            R"([
        [1, "a", null, 0],
        [2, "b", "updated_b", 1],
        [3, "c", "blob_c", 2]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "b0", "_ROW_ID"}, expected_with_row_id));

    // blob_as_descriptor read mode: the fallback-merged values come back as descriptors and
    // must still resolve to the same bytes
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    std::map<std::string, std::string> read_options = {{Options::BLOB_AS_DESCRIPTOR, "true"}};
    ASSERT_OK_AND_ASSIGN(auto desc_result, ReadTable(table_path, schema->field_names(), plan,
                                                     /*predicate=*/nullptr, read_options));
    ASSERT_TRUE(desc_result.chunked_array);
    auto desc_concat = arrow::Concatenate(desc_result.chunked_array->chunks()).ValueOrDie();
    auto desc_struct = std::dynamic_pointer_cast<arrow::StructArray>(desc_concat);
    ASSERT_TRUE(desc_struct);
    ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(desc_struct, {"b0"}));
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_array2));
    ASSERT_TRUE(resolved->Equals(expected_with_rk))
        << "result:" << resolved->ToString() << "\nexpected:" << expected_with_rk->ToString();
}

TEST_P(BlobTableInteTest, TestDataEvolutionBlobPartialUpdateMultipleLayers) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 arrow::field("f1", arrow::utf8()), BlobUtils::ToArrowField("b0")};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields);

    auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [1, "a", "blob_0"],
        [2, "b", "blob_1"],
        [3, "c", "blob_2"],
        [4, "d", "blob_3"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0,
                         WriteArray(table_path, {}, schema->field_names(), {src_array0}));
    ASSERT_OK(Commit(table_path, commit_msgs0));

    // second layer updates row 0 within rows [0, 1]
    auto update_array1 = MakeBlobUpdateArray(fields[2], {"update1_0", "PH"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, {"b0"}, {update_array1}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    // third layer updates row 3 within rows [2, 3]; each layer only partially covers the
    // range, the uncovered parts behave as placeholders
    auto update_array2 = MakeBlobUpdateArray(fields[2], {"PH", "update2_3"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs2, WriteArray(table_path, {}, {"b0"}, {update_array2}));
    SetFirstRowId(/*reset_first_row_id=*/2, commit_msgs2);
    ASSERT_OK(Commit(table_path, commit_msgs2));

    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [1, "a", "update1_0"],
        [2, "b", "blob_1"],
        [3, "c", "blob_2"],
        [4, "d", "update2_3"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));

    // row-range pushdown where the selected rows fall in one layer's file and in another
    // layer's uncovered gap: row 1 is a gap row for the third layer, row 2 for the second
    auto expected_middle = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [2, "b", "blob_1"],
        [3, "c", "blob_2"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_middle,
                          /*predicate=*/nullptr, /*row_ranges=*/{Range(1, 2)}));

    // per-row reads resolve every row independently
    const std::vector<std::string> expected_rows = {
        R"([[1, "a", "update1_0"]])", R"([[2, "b", "blob_1"]])", R"([[3, "c", "blob_2"]])",
        R"([[4, "d", "update2_3"]])"};
    for (int32_t i = 0; i < static_cast<int32_t>(expected_rows.size()); i++) {
        auto expected_single = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_rows[i])
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_single,
                              /*predicate=*/nullptr, /*row_ranges=*/{Range(i, i)}));
    }
}

TEST_P(BlobTableInteTest, TestDataEvolutionBlobPartialUpdateCompactedLayers) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 arrow::field("f1", arrow::utf8()), BlobUtils::ToArrowField("b0")};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields);

    // Mirrors the layer shapes of Java's BlobUpdateTest.testReadCompactedBlobSequenceGroups
    // (with a single base write, as the C++ write path assigns one sequence per commit):
    //   row id:  0    1    2    3    4    5    6    7    8    9
    //   seq1:   [b0   b1   b2   b3   b4   b5   b6   b7   b8   b9]
    //   seq2:   [u20  P    P    u23  u24] .    .    .    .    .
    //   seq3:    .    .    .    .    .   [P    u46  P    u48  P]
    //   seq4:   [P    u61  P    P    P    P    P    P    P    u69]
    //   result:  u20  u61  b2   u23  u24  b5   u46  b7   u48  u69
    auto base_array = PrepareBulkData(
        10,
        [](int32_t i) {
            return std::to_string(i) + ", \"name_" + std::to_string(i) + "\", \"blob_" +
                   std::to_string(i) + "\"";
        },
        fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0,
                         WriteArray(table_path, {}, schema->field_names(), {base_array}));
    ASSERT_OK(Commit(table_path, commit_msgs0));

    auto update_array1 = MakeBlobUpdateArray(fields[2], {"u20", "PH", "PH", "u23", "u24"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, {"b0"}, {update_array1}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    auto update_array2 = MakeBlobUpdateArray(fields[2], {"PH", "u46", "PH", "u48", "PH"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs2, WriteArray(table_path, {}, {"b0"}, {update_array2}));
    SetFirstRowId(/*reset_first_row_id=*/5, commit_msgs2);
    ASSERT_OK(Commit(table_path, commit_msgs2));

    auto update_array3 = MakeBlobUpdateArray(
        fields[2], {"PH", "u61", "PH", "PH", "PH", "PH", "PH", "PH", "PH", "u69"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs3, WriteArray(table_path, {}, {"b0"}, {update_array3}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs3);
    ASSERT_OK(Commit(table_path, commit_msgs3));

    const std::vector<std::string> expected_blobs = {"u20",    "u61", "blob_2", "u23", "u24",
                                                     "blob_5", "u46", "blob_7", "u48", "u69"};
    auto expected_row = [&](int32_t i) {
        return std::to_string(i) + ", \"name_" + std::to_string(i) + "\", \"" + expected_blobs[i] +
               "\"";
    };
    auto expected_full = PrepareBulkData(10, expected_row, fields);
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_full));

    // every row resolves independently under row-range pushdown
    for (int32_t i = 0; i < 10; i++) {
        auto expected_single = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
                                                      "[[" + expected_row(i) + "]]")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_single,
                              /*predicate=*/nullptr, /*row_ranges=*/{Range(i, i)}));
    }
}

TEST_P(BlobTableInteTest, TestDataEvolutionBlobPartialUpdateWithRowRanges) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 arrow::field("f1", arrow::utf8()), BlobUtils::ToArrowField("b0")};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields);

    auto base_array = PrepareBulkData(
        10,
        [](int32_t i) {
            return std::to_string(i) + ", \"name_" + std::to_string(i) + "\", \"blob_" +
                   std::to_string(i) + "\"";
        },
        fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0,
                         WriteArray(table_path, {}, schema->field_names(), {base_array}));
    ASSERT_OK(Commit(table_path, commit_msgs0));

    // partial update touching rows 1 and 9 only
    auto update_array = MakeBlobUpdateArray(
        fields[2], {"PH", "update_1", "PH", "PH", "PH", "PH", "PH", "PH", "PH", "update_9"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, {"b0"}, {update_array}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    // push down row ranges hitting updated and untouched rows
    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [1, "name_1", "update_1"],
        [5, "name_5", "blob_5"],
        [9, "name_9", "update_9"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array,
                          /*predicate=*/nullptr,
                          /*row_ranges=*/{Range(1, 1), Range(5, 5), Range(9, 9)}));

    // full read still resolves every row
    auto expected_full = PrepareBulkData(
        10,
        [](int32_t i) {
            std::string blob = (i == 1 || i == 9) ? "\"update_" + std::to_string(i) + "\""
                                                  : "\"blob_" + std::to_string(i) + "\"";
            return std::to_string(i) + ", \"name_" + std::to_string(i) + "\", " + blob;
        },
        fields);
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_full));
}

/// A BLOB sequence layer whose physical file covers only a strict subrange of the full row id
/// range: the newest group is internally Gap(2), File([u2, u3]), Gap(6). The table needs a
/// normal column to be creatable, but only the blob column is ever written, so the split
/// contains only blob files and the blob bunch itself carries the row-tracking fields: the
/// fallback reader must keep _ROW_ID correct and report each row's _SEQUENCE_NUMBER from the
/// layer that resolved it, without ever exposing the internal placeholder sentinel.
TEST_P(BlobTableInteTest, TestDataEvolutionBlobPartialUpdateRowTrackingWithSubrangeLayer) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("b0", /*nullable=*/true)};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // old layer: rows 0-9 all real
    std::vector<std::optional<std::string>> base_rows;
    for (int32_t i = 0; i < 10; i++) {
        base_rows.emplace_back("b" + std::to_string(i));
    }
    auto base_array = MakeBlobUpdateArray(fields[1], base_rows);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0, WriteArray(table_path, {}, {"b0"}, {base_array}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs0);
    ASSERT_OK(Commit(table_path, commit_msgs0));

    // new layer: first_row_id=2, row_count=2, covering only rows 2-3
    auto update_array = MakeBlobUpdateArray(fields[1], {"u2", "u3"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, {"b0"}, {update_array}));
    SetFirstRowId(/*reset_first_row_id=*/2, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    ASSERT_OK_AND_ASSIGN(auto scan_read,
                         ScanAndReadResult(table_path, {"b0", "_ROW_ID", "_SEQUENCE_NUMBER"}));
    ASSERT_TRUE(scan_read.chunked_array);
    auto concat_array = arrow::Concatenate(scan_read.chunked_array->chunks()).ValueOrDie();
    auto struct_array = std::dynamic_pointer_cast<arrow::StructArray>(concat_array);
    ASSERT_TRUE(struct_array);
    ASSERT_EQ(struct_array->length(), 10);
    auto blob_col =
        std::dynamic_pointer_cast<arrow::LargeBinaryArray>(struct_array->GetFieldByName("b0"));
    auto row_id_col = std::dynamic_pointer_cast<arrow::Int64Array>(
        struct_array->GetFieldByName(SpecialFields::RowId().Name()));
    auto seq_col = std::dynamic_pointer_cast<arrow::Int64Array>(
        struct_array->GetFieldByName(SpecialFields::SequenceNumber().Name()));
    ASSERT_TRUE(blob_col && row_id_col && seq_col);
    int64_t old_layer_seq = seq_col->Value(0);
    int64_t new_layer_seq = seq_col->Value(2);
    ASSERT_GT(new_layer_seq, old_layer_seq);
    for (int64_t i = 0; i < 10; i++) {
        ASSERT_FALSE(blob_col->IsNull(i));
        std::string expected_blob =
            (i == 2 || i == 3) ? "u" + std::to_string(i) : "b" + std::to_string(i);
        // the leading and trailing gaps never expose the internal placeholder sentinel
        ASSERT_EQ(blob_col->GetString(i), expected_blob) << "row " << i;
        ASSERT_EQ(row_id_col->Value(i), i);
        ASSERT_EQ(seq_col->Value(i), (i == 2 || i == 3) ? new_layer_seq : old_layer_seq)
            << "row " << i;
    }

    // row-range pushdown with the row-tracking projection: the selection removes rows inside
    // the blob files, so batch positions must still map back to the right file row indexes
    ASSERT_OK_AND_ASSIGN(auto range_read,
                         ScanAndReadResult(table_path, {"b0", "_ROW_ID", "_SEQUENCE_NUMBER"},
                                           /*predicate=*/nullptr,
                                           /*row_ranges=*/{Range(1, 2), Range(8, 8)}));
    ASSERT_TRUE(range_read.chunked_array);
    auto range_concat = arrow::Concatenate(range_read.chunked_array->chunks()).ValueOrDie();
    auto range_struct = std::dynamic_pointer_cast<arrow::StructArray>(range_concat);
    ASSERT_TRUE(range_struct);
    ASSERT_EQ(range_struct->length(), 3);
    auto range_blob_col =
        std::dynamic_pointer_cast<arrow::LargeBinaryArray>(range_struct->GetFieldByName("b0"));
    auto range_row_id_col = std::dynamic_pointer_cast<arrow::Int64Array>(
        range_struct->GetFieldByName(SpecialFields::RowId().Name()));
    auto range_seq_col = std::dynamic_pointer_cast<arrow::Int64Array>(
        range_struct->GetFieldByName(SpecialFields::SequenceNumber().Name()));
    ASSERT_TRUE(range_blob_col && range_row_id_col && range_seq_col);
    const std::vector<int64_t> expected_row_ids = {1, 2, 8};
    const std::vector<std::string> expected_blobs = {"b1", "u2", "b8"};
    for (int64_t i = 0; i < 3; i++) {
        ASSERT_EQ(range_blob_col->GetString(i), expected_blobs[i]) << "row " << i;
        ASSERT_EQ(range_row_id_col->Value(i), expected_row_ids[i]) << "row " << i;
        ASSERT_EQ(range_seq_col->Value(i), expected_row_ids[i] == 2 ? new_layer_seq : old_layer_seq)
            << "row " << i;
    }
}

/// A row that is a placeholder in every BLOB layer degrades to a null blob but keeps its
/// _ROW_ID and reports -1 as its _SEQUENCE_NUMBER. Only the blob column is ever written, so
/// the blob bunch itself carries the row-tracking fields.
TEST_P(BlobTableInteTest, TestDataEvolutionBlobPartialUpdateAllPlaceholderRowTracking) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("b0", /*nullable=*/true)};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // the base layer itself holds a placeholder at row 1, so after the second layer also
    // leaves it untouched, row 1 is a placeholder in every layer
    auto base_array = MakeBlobUpdateArray(fields[1], {"blob_a", "PH", "blob_c"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0, WriteArray(table_path, {}, {"b0"}, {base_array}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs0);
    ASSERT_OK(Commit(table_path, commit_msgs0));

    auto update_array = MakeBlobUpdateArray(fields[1], {"PH", "PH", "update_c"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, {"b0"}, {update_array}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    ASSERT_OK_AND_ASSIGN(auto scan_read,
                         ScanAndReadResult(table_path, {"b0", "_ROW_ID", "_SEQUENCE_NUMBER"}));
    ASSERT_TRUE(scan_read.chunked_array);
    auto concat_array = arrow::Concatenate(scan_read.chunked_array->chunks()).ValueOrDie();
    auto struct_array = std::dynamic_pointer_cast<arrow::StructArray>(concat_array);
    ASSERT_TRUE(struct_array);
    ASSERT_EQ(struct_array->length(), 3);
    auto blob_col =
        std::dynamic_pointer_cast<arrow::LargeBinaryArray>(struct_array->GetFieldByName("b0"));
    auto row_id_col = std::dynamic_pointer_cast<arrow::Int64Array>(
        struct_array->GetFieldByName(SpecialFields::RowId().Name()));
    auto seq_col = std::dynamic_pointer_cast<arrow::Int64Array>(
        struct_array->GetFieldByName(SpecialFields::SequenceNumber().Name()));
    ASSERT_TRUE(blob_col && row_id_col && seq_col);

    ASSERT_EQ(blob_col->GetString(0), "blob_a");
    ASSERT_EQ(blob_col->GetString(2), "update_c");
    // the all-placeholder row: null blob, row id kept, sequence number -1
    ASSERT_TRUE(blob_col->IsNull(1));
    ASSERT_EQ(row_id_col->Value(1), 1);
    ASSERT_EQ(seq_col->Value(1), -1);
    for (int64_t i : {static_cast<int64_t>(0), static_cast<int64_t>(2)}) {
        ASSERT_EQ(row_id_col->Value(i), i);
        ASSERT_GE(seq_col->Value(i), 0);
    }
    ASSERT_GT(seq_col->Value(2), seq_col->Value(0));
}

/// A normal (full-row) write never interprets blob bytes: a user value whose bytes exactly
/// equal the placeholder sentinel must be stored verbatim (not persisted as a bin_length -2
/// entry) and read back unchanged. The sentinel is reserved only inside the data-evolution
/// partial-update channels (see BlobDefs::kPlaceholderSentinel).
TEST_P(BlobTableInteTest, TestBlobValueEqualToPlaceholderSentinelBytes) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 arrow::field("f1", arrow::utf8()),
                                 BlobUtils::ToArrowField("b0", /*nullable=*/true)};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields);
    std::string sentinel_bytes = std::string(BlobDefs::PlaceholderSentinelView());

    // full-row write: row 0's blob is exactly the sentinel bytes
    auto struct_type = arrow::struct_(fields);
    arrow::StructBuilder struct_builder(
        struct_type, arrow::default_memory_pool(),
        {std::make_shared<arrow::Int32Builder>(), std::make_shared<arrow::StringBuilder>(),
         std::make_shared<arrow::LargeBinaryBuilder>()});
    auto f0_builder = static_cast<arrow::Int32Builder*>(struct_builder.field_builder(0));
    auto f1_builder = static_cast<arrow::StringBuilder*>(struct_builder.field_builder(1));
    auto b0_builder = static_cast<arrow::LargeBinaryBuilder*>(struct_builder.field_builder(2));
    ASSERT_TRUE(struct_builder.Append().ok());
    ASSERT_TRUE(f0_builder->Append(1).ok());
    ASSERT_TRUE(f1_builder->Append("a").ok());
    ASSERT_TRUE(b0_builder->Append(sentinel_bytes.data(), sentinel_bytes.size()).ok());
    ASSERT_TRUE(struct_builder.Append().ok());
    ASSERT_TRUE(f0_builder->Append(2).ok());
    ASSERT_TRUE(f1_builder->Append("b").ok());
    ASSERT_TRUE(b0_builder->Append("normal", 6).ok());
    std::shared_ptr<arrow::Array> src_array;
    ASSERT_TRUE(struct_builder.Finish(&src_array).ok());
    auto src_struct = std::dynamic_pointer_cast<arrow::StructArray>(src_array);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0,
                         WriteArray(table_path, {}, schema->field_names(), {src_struct}));
    ASSERT_OK(Commit(table_path, commit_msgs0));

    // read back unchanged: the sentinel-equal bytes are a normal value
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), src_struct));

    // a value that merely starts with the sentinel bytes stays a normal value even through a
    // partial-update fallback (placeholders are identified by exact equality only)
    std::string prefixed_bytes = sentinel_bytes + "suffix";
    auto update_array = MakeBlobUpdateArray(fields[2], {prefixed_bytes, "updated_b"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, {"b0"}, {update_array}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    ASSERT_OK_AND_ASSIGN(auto scan_read, ScanAndReadResult(table_path, schema->field_names()));
    ASSERT_TRUE(scan_read.chunked_array);
    auto concat_array = arrow::Concatenate(scan_read.chunked_array->chunks()).ValueOrDie();
    auto struct_result = std::dynamic_pointer_cast<arrow::StructArray>(concat_array);
    ASSERT_TRUE(struct_result);
    ASSERT_EQ(struct_result->length(), 2);
    auto blob_col =
        std::dynamic_pointer_cast<arrow::LargeBinaryArray>(struct_result->GetFieldByName("b0"));
    ASSERT_TRUE(blob_col);
    ASSERT_FALSE(blob_col->IsNull(0));
    ASSERT_EQ(blob_col->GetString(0), prefixed_bytes);
    ASSERT_EQ(blob_col->GetString(1), "updated_b");
}

TEST_P(BlobTableInteTest, TestBlobSentinelValueInBaseLayerDegradesToNull) {
    // Pins the accepted collision of the byte-identified placeholder protocol (see
    // BlobDefs::kPlaceholderSentinel): the fallback merge byte-compares every layer, so a user
    // blob equal to the sentinel bytes that a later partial update leaves untouched reads as a
    // placeholder in every layer and degrades to a null blob instead of falling back.
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 arrow::field("f1", arrow::utf8()),
                                 BlobUtils::ToArrowField("b0", /*nullable=*/true)};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields);
    std::string sentinel_bytes = std::string(BlobDefs::PlaceholderSentinelView());

    // the full-row write stores row 0's sentinel-equal bytes verbatim (the write channel is off)
    std::string src_json =
        std::string(R"([[1, "a", ")") + sentinel_bytes + R"("], [2, "b", "blob_b"]])";
    auto src_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), src_json).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs0,
                         WriteArray(table_path, {}, schema->field_names(), {src_array}));
    ASSERT_OK(Commit(table_path, commit_msgs0));

    // the partial update leaves row 0 untouched, so its base value collides with the
    // placeholder markers of the newer layer
    auto update_array = MakeBlobUpdateArray(fields[2], {"PH", "updated_b"});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, {"b0"}, {update_array}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    // row 0's non-blob fields survive (they come from the data file); only the blob degrades
    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
        [1, "a", null],
        [2, "b", "updated_b"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));
}

TEST_P(BlobTableInteTest, TestUserSuppliedInternalPlaceholderOptionsIgnored) {
    // blob.internal.* options are reserved for the data-evolution write and read paths. Set in
    // the user table options they must be ignored: were the write key honored, this full-row
    // write would persist the sentinel-equal user value as a placeholder entry that no older
    // layer can resolve, making the table unreadable.
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 arrow::field("f1", arrow::utf8()),
                                 BlobUtils::ToArrowField("b0", /*nullable=*/true)};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"},
                                                  {BlobDefs::kWritePlaceholderKey, "true"},
                                                  {BlobDefs::kEmitPlaceholderSentinelKey, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields);
    std::string sentinel_bytes = std::string(BlobDefs::PlaceholderSentinelView());

    std::string src_json =
        std::string(R"([[1, "a", ")") + sentinel_bytes + R"("], [2, "b", "blob_b"]])";
    auto src_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), src_json).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {src_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // the sentinel-equal value round-trips verbatim: the user-supplied keys were stripped
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), src_array));
}

TEST_P(BlobTableInteTest, TestDataEvolutionBlobOnlyFirstCommitFailsWithoutFirstRowId) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 arrow::field("f1", arrow::utf8()), BlobUtils::ToArrowField("b0")};
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    auto blob_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[2]}), R"([
        ["blob_0"]
    ])")
            .ValueOrDie());
    std::vector<std::string> blob_write_cols = {"b0"};
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, blob_write_cols, {blob_array}));

    ASSERT_EQ(commit_msgs.size(), 1);
    auto commit_msg = std::dynamic_pointer_cast<CommitMessageImpl>(commit_msgs[0]);
    ASSERT_TRUE(commit_msg);
    ASSERT_EQ(commit_msg->data_increment_.new_files_.size(), 1);
    const auto& blob_file = commit_msg->data_increment_.new_files_[0];
    ASSERT_TRUE(BlobUtils::IsBlobFile(blob_file->file_name));
    ASSERT_FALSE(blob_file->first_row_id.has_value());

    ASSERT_NOK_WITH_MSG(Commit(table_path, commit_msgs), "blobStart 0 should be less than start 0");
}

TEST_P(BlobTableInteTest, TestMultipleAppendsDifferentFirstRowIds) {
    CreateTable();
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    // First commit, firstRowId = 0
    // write field: f0, f1
    std::vector<std::string> write_cols1 = {"f0", "f1"};
    auto src_array1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[0], fields_[1]}), R"([
        [1, "a"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, write_cols1, {src_array1}));
    SetFirstRowId(0, commit_msgs1);

    // write field: f2
    std::vector<std::string> write_cols2 = {"f2"};
    auto src_array2 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[2]}), R"([
        ["b"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs2, WriteArray(table_path, {}, write_cols2, {src_array2}));
    SetFirstRowId(0, commit_msgs2);

    std::vector<std::shared_ptr<CommitMessage>> total_msgs;
    total_msgs.insert(total_msgs.end(), commit_msgs1.begin(), commit_msgs1.end());
    total_msgs.insert(total_msgs.end(), commit_msgs2.begin(), commit_msgs2.end());
    ASSERT_OK(Commit(table_path, total_msgs));

    // Second commit, firstRowId = 1
    // write field: f0, f1
    std::vector<std::string> write_cols3 = {"f0", "f1"};
    auto src_array3 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[0], fields_[1]}), R"([
        [2, "c"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs3, WriteArray(table_path, {}, write_cols3, {src_array3}));
    SetFirstRowId(1, commit_msgs3);
    ASSERT_OK(Commit(table_path, commit_msgs3));

    // Third commit
    // write field: f2
    std::vector<std::string> write_cols4 = {"f2"};
    auto src_array4 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[2]}), R"([
        ["d"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs4, WriteArray(table_path, {}, write_cols4, {src_array4}));
    SetFirstRowId(1, commit_msgs4);
    ASSERT_OK(Commit(table_path, commit_msgs4));

    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "a", "b"],
        [2, "c", "d"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));

    // read with row tracking
    auto expected_row_tracking_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({
                                                      fields_[0],
                                                      fields_[1],
                                                      fields_[2],
                                                      SpecialFields::RowId().field_,
                                                      SpecialFields::SequenceNumber().field_,
                                                  }),
                                                  R"([
        [1, "a", "b", 0, 1],
        [2, "c", "d", 1, 3]
    ])")
            .ValueOrDie());

    ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2", "_ROW_ID", "_SEQUENCE_NUMBER"},
                          expected_row_tracking_array));
}

TEST_P(BlobTableInteTest, TestMoreDataWithDataEvolution) {
    CreateTable();
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    // write field: f0, f1
    std::vector<std::string> write_cols1 = {"f0", "f1"};
    // row0: 0, a0; row1: 1, a1 ...
    auto src_array1 = PrepareBulkData(
        10000, [](int32_t i) { return std::to_string(i) + ", \"a" + std::to_string(i) + "\""; },
        {fields_[0], fields_[1]});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, write_cols1, {src_array1}));
    SetFirstRowId(0, commit_msgs1);

    // write field: f2
    std::vector<std::string> write_cols2 = {"f2"};
    // row0: b0; row1: b1 ...
    auto src_array2 = PrepareBulkData(
        10000, [](int32_t i) { return "\"b" + std::to_string(i) + "\""; }, {fields_[2]});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs2, WriteArray(table_path, {}, write_cols2, {src_array2}));
    SetFirstRowId(0, commit_msgs2);

    std::vector<std::shared_ptr<CommitMessage>> total_msgs;
    total_msgs.insert(total_msgs.end(), commit_msgs1.begin(), commit_msgs1.end());
    total_msgs.insert(total_msgs.end(), commit_msgs2.begin(), commit_msgs2.end());
    ASSERT_OK(Commit(table_path, total_msgs));

    // write field: f2
    std::vector<std::string> write_cols3 = {"f2"};
    // row0: c0; row1: c1 ...
    auto src_array3 = PrepareBulkData(
        10000, [](int32_t i) { return "\"c" + std::to_string(i) + "\""; }, {fields_[2]});
    ASSERT_OK_AND_ASSIGN(auto commit_msgs3, WriteArray(table_path, {}, write_cols3, {src_array3}));
    ASSERT_OK(Commit(table_path, commit_msgs3));

    // row0: 0, a0, c0; row1: 1, a1, c1 ...
    auto expected_array = PrepareBulkData(10000,
                                          [](int32_t i) {
                                              return std::to_string(i) + ", \"a" +
                                                     std::to_string(i) + "\", \"c" +
                                                     std::to_string(i) + "\"";
                                          },
                                          {fields_[0], fields_[1], fields_[2]});
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));
}

TEST_P(BlobTableInteTest, TestBlobWriteMultiRound) {
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},        {Options::FILE_FORMAT, GetParam()},
        {Options::FILE_SYSTEM, "local"},          {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::BLOB_TARGET_FILE_SIZE, "1000"}, {Options::TARGET_FILE_SIZE, "100"},
        {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(/*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    // write field: f0, f1, f2
    std::vector<std::string> write_cols = schema->field_names();
    int32_t offset = 0;
    auto array_1 = PrepareBulkData(5000,
                                   [offset](int32_t i) {
                                       return std::to_string(offset + i) + ", \"a" +
                                              std::to_string(offset + i) + "\", \"c" +
                                              std::to_string(offset + i) + "\"";
                                   },
                                   {fields_[0], fields_[1], fields_[2]});

    offset = 5000;
    auto array_2 = PrepareBulkData(5000,
                                   [offset](int32_t i) {
                                       return std::to_string(offset + i) + ", \"a" +
                                              std::to_string(offset + i) + "\", \"c" +
                                              std::to_string(offset + i) + "\"";
                                   },
                                   {fields_[0], fields_[1], fields_[2]});

    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, write_cols, {array_1, array_2}));
    ASSERT_OK(Commit(table_path, commit_msgs));
    auto concat_array = arrow::Concatenate({array_1, array_2}).ValueOrDie();
    auto expect_array = std::dynamic_pointer_cast<arrow::StructArray>(concat_array);
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expect_array));
}

TEST_P(BlobTableInteTest, TestExternalPath) {
    // create external path dir
    auto external_dir = UniqueTestDirectory::Create("local");
    ASSERT_TRUE(external_dir);
    std::string external_test_dir = external_dir->Str();

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, GetParam()},
        {Options::FILE_SYSTEM, "local"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::DATA_FILE_EXTERNAL_PATHS, "FILE://" + external_test_dir}};
    CreateTable(/*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    // write field: f0, f1
    std::vector<std::string> write_cols0 = {"f0", "f1"};
    auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[0], fields_[1]}), R"([
        [1, "a"],
        [2, "c"]
    ])")
            .ValueOrDie());

    ASSERT_OK_AND_ASSIGN(auto commit_msgs0, WriteArray(table_path, {}, write_cols0, {src_array0}));
    ASSERT_OK(Commit(table_path, commit_msgs0));

    // write field: f0, f2
    std::vector<std::string> write_cols1 = {"f0", "f2"};
    auto src_array1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[0], fields_[2]}), R"([
        [10, "b"],
        [20, "d"]
    ])")
            .ValueOrDie());

    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, write_cols1, {src_array1}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
    ASSERT_OK(Commit(table_path, commit_msgs1));

    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [10, "a", "b"],
        [20, "c", "d"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));

    // read with row tracking
    auto expected_row_tracking_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(
            arrow::struct_({fields_[1], fields_[0], fields_[2], SpecialFields::RowId().field_,
                            SpecialFields::SequenceNumber().field_}),
            R"([
        ["a", 10, "b", 0, 2],
        ["c", 20, "d", 1, 2]
    ])")
            .ValueOrDie());

    ASSERT_OK(ScanAndRead(table_path, {"f1", "f0", "f2", "_ROW_ID", "_SEQUENCE_NUMBER"},
                          expected_row_tracking_array));
}

TEST_P(BlobTableInteTest, TestPartitionWithPredicate) {
    auto file_format = GetParam();
    std::vector<std::string> partition_keys = {"f0"};
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},         {Options::FILE_FORMAT, GetParam()},
        {Options::FILE_SYSTEM, "local"},           {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"}, {"parquet.write.max-row-group-length", "1"}};

    CreateTable(partition_keys, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    // write field: f0, f1 for partition f0 = "11"
    std::vector<std::string> write_cols0 = {"f0", "f1"};
    auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[0], fields_[1]}), R"([
        [11, "2024"],
        [11, "2025"],
        [11, "2026"]
    ])")
            .ValueOrDie());

    ASSERT_OK_AND_ASSIGN(auto commit_msgs0,
                         WriteArray(table_path, {{"f0", "11"}}, write_cols0, {src_array0}));

    // write field: f2 for partition f0 = "11"
    std::vector<std::string> write_cols1 = {"f2"};
    auto src_array1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[2]}), R"([
        ["a"],
        ["b"],
        ["c"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto commit_msgs1,
                         WriteArray(table_path, {{"f0", "11"}}, write_cols1, {src_array1}));
    std::vector<std::shared_ptr<CommitMessage>> total_msgs;
    total_msgs.insert(total_msgs.end(), commit_msgs0.begin(), commit_msgs0.end());
    total_msgs.insert(total_msgs.end(), commit_msgs1.begin(), commit_msgs1.end());
    SetFirstRowId(0, total_msgs);
    ASSERT_OK(Commit(table_path, total_msgs));

    // write field: f0, f1 for partition f0 = "22"
    std::vector<std::string> write_cols3 = {"f0", "f1"};
    auto src_array3 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[0], fields_[1]}), R"([
        [22, "2027"],
        [22, "2028"],
        [22, "2029"]
    ])")
            .ValueOrDie());

    ASSERT_OK_AND_ASSIGN(auto commit_msgs3,
                         WriteArray(table_path, {{"f0", "22"}}, write_cols3, {src_array3}));
    SetFirstRowId(3, commit_msgs3);
    ASSERT_OK(Commit(table_path, commit_msgs3));
    {
        // only set data field predicate, file with field all null will be returned
        auto equal = PredicateBuilder::Equal(/*field_index=*/2, /*field_name=*/"f2",
                                             FieldType::STRING, Literal(FieldType::STRING, "a", 1));
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [11, "2024", "a"],
        [11, "2025", "b"],
        [11, "2026", "c"],
        [22, "2027", null],
        [22, "2028", null],
        [22, "2029", null]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array, equal));
    }
    {
        // only set partition predicate
        auto equal = PredicateBuilder::Equal(/*field_index=*/0, /*field_name=*/"f0", FieldType::INT,
                                             Literal(11));
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [11, "2024", "a"],
        [11, "2025", "b"],
        [11, "2026", "c"]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array, equal));
    }
    {
        // set partition predicate and data field predicate, blob type not support predicate
        auto equal =
            PredicateBuilder::Equal(/*field_index=*/1, /*field_name=*/"f1", FieldType::STRING,
                                    Literal(FieldType::BLOB, "2024", 4));
        auto greater_than = PredicateBuilder::GreaterThan(/*field_index=*/0, /*field_name=*/"f0",
                                                          FieldType::INT, Literal(100));
        ASSERT_OK_AND_ASSIGN(auto predicate, PredicateBuilder::And({equal, greater_than}));
        ASSERT_NOK_WITH_MSG(
            ScanAndRead(table_path, schema->field_names(), /*expected_array=*/nullptr, predicate),
            "Invalid type large_binary for predicate");
    }
    {
        // read with row tracking
        auto equal = PredicateBuilder::Equal(/*field_index=*/0, /*field_name=*/"f0", FieldType::INT,
                                             Literal(11));

        auto expected_row_tracking_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(
                arrow::struct_({fields_[0], fields_[1], fields_[2], SpecialFields::RowId().field_,
                                SpecialFields::SequenceNumber().field_}),
                R"([
        [11, "2024", "a", 0, 1],
        [11, "2025", "b", 1, 1],
        [11, "2026", "c", 2, 1]
    ])")
                .ValueOrDie());

        ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2", "_ROW_ID", "_SEQUENCE_NUMBER"},
                              expected_row_tracking_array, equal));
    }
}

TEST_P(BlobTableInteTest, TestPredicate) {
    if (GetParam() == "avro") {
        // Avro does not have stats.
        return;
    }
    CreateTable();
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    // write field: f0, f1, f2
    std::vector<std::string> write_cols0 = schema->field_names();
    auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "a", "b"]
    ])")
            .ValueOrDie());

    ASSERT_OK_AND_ASSIGN(auto commit_msgs, WriteArray(table_path, {}, write_cols0, {src_array0}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // write field: f2
    std::vector<std::string> write_cols1 = {"f2"};
    auto src_array1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[2]}), R"([
        ["c"]
    ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(commit_msgs, WriteArray(table_path, {}, write_cols1, {src_array1}));
    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs);
    ASSERT_OK(Commit(table_path, commit_msgs));
    {
        // test no predicate
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "a", "c"]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));
    }
    {
        // test predicate with f2
        auto predicate =
            PredicateBuilder::NotEqual(/*field_index=*/2, /*field_name=*/"f2", FieldType::STRING,
                                       Literal(FieldType::STRING, "b", 1));
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "a", "c"]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array, predicate));
    }
    {
        // test predicate with f1
        auto predicate =
            PredicateBuilder::NotEqual(/*field_index=*/1, /*field_name=*/"f1", FieldType::STRING,
                                       Literal(FieldType::STRING, "a", 1));
        ASSERT_NOK_WITH_MSG(
            ScanAndRead(table_path, schema->field_names(), /*expected_array=*/nullptr, predicate),
            "Invalid type large_binary for predicate");
    }
    {
        // test predicate with f2
        auto predicate =
            PredicateBuilder::NotEqual(/*field_index=*/2, /*field_name=*/"f2", FieldType::STRING,
                                       Literal(FieldType::STRING, "c", 1));
        ASSERT_OK(
            ScanAndRead(table_path, schema->field_names(), /*expected_array=*/nullptr, predicate));
    }
}

TEST_P(BlobTableInteTest, TestIOException) {
    std::string table_path;
    // write and commit with I/O exception
    bool write_run_complete = false;
    auto io_hook = IOHook::GetInstance();
    for (size_t i = 0; i < 2000; i += paimon::test::RandomNumber(20, 30)) {
        ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
        dir_ = UniqueTestDirectory::Create("local");
        CreateTable();
        table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
        auto schema = arrow::schema(fields_);

        io_hook->Reset(i, IOHook::Mode::RETURN_ERROR);
        // write field: f0, f1, f2
        std::vector<std::string> write_cols0 = schema->field_names();
        auto src_array0 = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [10, "a", "b"],
        [20, "aa", "bb"],
        [23, "aaa", "bbb"]
    ])")
                .ValueOrDie());
        auto commit_msgs0_result = WriteArray(table_path, {}, write_cols0, {src_array0});
        CHECK_HOOK_STATUS(commit_msgs0_result.status(), i);
        CHECK_HOOK_STATUS(Commit(table_path, commit_msgs0_result.value()), i);

        // write field: f2, f0
        std::vector<std::string> write_cols1 = {"f2", "f0"};
        auto src_array1 = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[2], fields_[0]}), R"([
            ["c", 100],
            ["cc", 200],
            ["ccc", 300]
        ])")
                .ValueOrDie());
        auto commit_msgs1_result = WriteArray(table_path, {}, write_cols1, {src_array1});
        CHECK_HOOK_STATUS(commit_msgs1_result.status(), i);
        auto commit_msgs1 = std::move(commit_msgs1_result).value();
        SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
        CHECK_HOOK_STATUS(Commit(table_path, commit_msgs1), i);
        write_run_complete = true;
        break;
    }
    ASSERT_TRUE(write_run_complete);

    // scan and read with I / O exception
    bool read_run_complete = false;
    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(
            arrow::struct_({fields_[1], fields_[0], SpecialFields::SequenceNumber().field_,
                            SpecialFields::RowId().field_, fields_[2]}),
            R"([
        ["a", 100, 2, 0, "c"],
        ["aa", 200, 2, 1, "cc"],
        ["aaa", 300, 2, 2, "ccc"]
    ])")
            .ValueOrDie());

    for (size_t i = 0; i < 2000; i++) {
        ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
        io_hook->Reset(i, IOHook::Mode::RETURN_ERROR);
        CHECK_HOOK_STATUS(ScanAndRead(table_path, {"f1", "f0", "_SEQUENCE_NUMBER", "_ROW_ID", "f2"},
                                      expected_array),
                          i);
        read_run_complete = true;
        break;
    }
    ASSERT_TRUE(read_run_complete);
}

TEST_P(BlobTableInteTest, TestReadTableWithDenseStats) {
    auto file_format = GetParam();
    if (file_format == "avro") {
        return;
    }
    std::string table_path =
        paimon::test::GetDataDir() + file_format +
        "/blob_data_evolution_with_dense_stats.db/blob_data_evolution_with_dense_stats/";
    std::vector<DataField> read_fields = {DataField(0, BlobUtils::ToArrowField("f0")),
                                          DataField(1, arrow::field("f1", arrow::int32())),
                                          DataField(2, arrow::field("f2", arrow::int32())),
                                          DataField(3, arrow::field("f3", arrow::float64())),
                                          SpecialFields::RowId(),
                                          SpecialFields::SequenceNumber()};

    std::shared_ptr<arrow::DataType> arrow_data_type =
        DataField::ConvertDataFieldsToArrowStructType(read_fields);
    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow_data_type, R"([
        ["Lily", 2, 102, 2.1, 0, 2],
        ["Alice", 4, 104, 3.1, 1, 2]
    ])")
            .ValueOrDie());

    {
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              expected_array));
    }
    {
        auto predicate =
            PredicateBuilder::Equal(/*field_index=*/0, /*field_name=*/"f0", FieldType::BLOB,
                                    Literal(FieldType::BLOB, "Alice", 5));
        ASSERT_NOK_WITH_MSG(
            ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                        expected_array, predicate),
            "Invalid type large_binary for predicate");
    }
    {
        auto predicate = PredicateBuilder::Equal(/*field_index=*/2, /*field_name=*/"f2",
                                                 FieldType::INT, Literal(102));
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              expected_array, predicate));
    }
    {
        auto predicate = PredicateBuilder::Equal(/*field_index=*/2, /*field_name=*/"f2",
                                                 FieldType::INT, Literal(12));
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              /*expected_array=*/nullptr, predicate));
    }
    {
        auto predicate = PredicateBuilder::GreaterThan(/*field_index=*/1, /*field_name=*/"f1",
                                                       FieldType::INT, Literal(6));
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              /*expected_array=*/nullptr, predicate));
    }
    {
        // f3 does not have stats, therefore data will not be filtered
        auto predicate = PredicateBuilder::GreaterThan(/*field_index=*/3, /*field_name=*/"f3",
                                                       FieldType::DOUBLE, Literal(5.1));
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              expected_array, predicate));
    }
}

TEST_P(BlobTableInteTest, TestDataEvolutionAndAlterTable) {
    auto file_format = GetParam();
    if (file_format == "avro") {
        return;
    }
    std::string table_path = paimon::test::GetDataDir() + file_format +
                             "/blob_append_table_alter_table_with_cast_with_data_evolution.db/"
                             "blob_append_table_alter_table_with_cast_with_data_evolution/";
    std::vector<DataField> read_fields = {
        DataField(6, arrow::field("f4", arrow::timestamp(arrow::TimeUnit::NANO))),
        DataField(0, arrow::field("key0", arrow::int32())),
        DataField(1, arrow::field("key1", arrow::int32())),
        DataField(2, arrow::field("f3", arrow::int32())),
        DataField(3, arrow::field("f1", arrow::utf8())),
        DataField(4, arrow::field("f2", arrow::decimal128(6, 3))),
        DataField(5, arrow::field("f0", arrow::boolean())),
        DataField(8, BlobUtils::ToArrowField("blob")),
        DataField(9, arrow::field("f6", arrow::int32())),
        SpecialFields::RowId(),
        SpecialFields::SequenceNumber()};

    std::shared_ptr<arrow::DataType> arrow_data_type =
        DataField::ConvertDataFieldsToArrowStructType(read_fields);

    {
        // only read blob column
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(
                arrow::struct_({BlobUtils::ToArrowField("blob")}), R"([
            ["Lily"],
            ["Alice"],
            ["Bob"],
            ["Cindy"],
            ["Dave"],
            ["Apple"],
            ["Banana"],
            ["Cherry"],
            ["Durian"],
            ["Elderberry"]
        ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, {"blob"}, expected_array));
    }
    {
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow_data_type, R"([
["1970-01-05T00:00", 0, 1, 100, "2024-11-26 06:38:56.001000001", "0.020", true, "Lily", null, 0, 1],
["1969-11-18T00:00", 0, 1, 110, "2024-11-26 06:38:56.011000011", "11.120", true, "Alice", null, 1, 1],
["1971-03-21T00:00", 0, 1, 120, "2024-11-26 06:38:56.021000021", "22.220", false, "Bob", null, 2, 1],
["1957-11-01T00:00", 0, 1, 130, "2024-11-26 06:38:56.031000031", "333.320", false, "Cindy", null, 3, 1],
["2091-09-07T00:00", 0, 1, 140, "2024-11-26 06:38:56.041000041", "444.420", true, "Dave", null, 4, 1],
["2024-11-26T06:38:56.054000154", 0, 1, 150, "2024-11-26 15:28:31", "55.002", true, "Apple", 56, 5, 3],
["2024-11-26T06:38:56.064000164", 0, 1, 160, "2024-11-26 15:28:41", "666.012", false, "Banana", 66, 6, 3],
["2024-11-26T06:38:56.074000174", 0, 1, 170, "2024-11-26 15:28:51", "-77.022", true, "Cherry", 76, 7, 3],
["2024-11-26T06:38:56.084000184", 0, 1, 180, "2024-11-26 15:29:01", "8.032", true, "Durian", -86, 8, 3],
["2024-11-26T06:38:56.094000194", 0, 1, 190, "I'm strange", "-999.420", false, "Elderberry", 96, 9, 3]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              expected_array));
    }
    {
        // only files with schema-1 will be skipped, while files with schema-0 will be reserved
        // as type change
        auto predicate = PredicateBuilder::GreaterThan(/*field_index=*/3, /*field_name=*/"f3",
                                                       FieldType::INT, Literal(200));
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow_data_type, R"([
["1970-01-05T00:00", 0, 1, 100, "2024-11-26 06:38:56.001000001", "0.020", true, "Lily", null, 0, 1],
["1969-11-18T00:00", 0, 1, 110, "2024-11-26 06:38:56.011000011", "11.120", true, "Alice", null, 1, 1],
["1971-03-21T00:00", 0, 1, 120, "2024-11-26 06:38:56.021000021", "22.220", false, "Bob", null, 2, 1],
["1957-11-01T00:00", 0, 1, 130, "2024-11-26 06:38:56.031000031", "333.320", false, "Cindy", null, 3, 1],
["2091-09-07T00:00", 0, 1, 140, "2024-11-26 06:38:56.041000041", "444.420", true, "Dave", null, 4, 1]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              expected_array, predicate));
    }
    {
        // files with schema-0 will be reserved as f6 does not exist in schema-0 (null count =
        // null), files with schema-1 will also be reserved
        auto predicate =
            PredicateBuilder::IsNotNull(/*field_index=*/8, /*field_name=*/"f6", FieldType::INT);
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow_data_type, R"([
["1970-01-05T00:00", 0, 1, 100, "2024-11-26 06:38:56.001000001", "0.020", true, "Lily", null, 0, 1],
["1969-11-18T00:00", 0, 1, 110, "2024-11-26 06:38:56.011000011", "11.120", true, "Alice", null, 1, 1],
["1971-03-21T00:00", 0, 1, 120, "2024-11-26 06:38:56.021000021", "22.220", false, "Bob", null, 2, 1],
["1957-11-01T00:00", 0, 1, 130, "2024-11-26 06:38:56.031000031", "333.320", false, "Cindy", null, 3, 1],
["2091-09-07T00:00", 0, 1, 140, "2024-11-26 06:38:56.041000041", "444.420", true, "Dave", null, 4, 1],
["2024-11-26T06:38:56.054000154", 0, 1, 150, "2024-11-26 15:28:31", "55.002", true, "Apple", 56, 5, 3],
["2024-11-26T06:38:56.064000164", 0, 1, 160, "2024-11-26 15:28:41", "666.012", false, "Banana", 66, 6, 3],
["2024-11-26T06:38:56.074000174", 0, 1, 170, "2024-11-26 15:28:51", "-77.022", true, "Cherry", 76, 7, 3],
["2024-11-26T06:38:56.084000184", 0, 1, 180, "2024-11-26 15:29:01", "8.032", true, "Durian", -86, 8, 3],
["2024-11-26T06:38:56.094000194", 0, 1, 190, "I'm strange", "-999.420", false, "Elderberry", 96, 9, 3]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              expected_array, predicate));
    }
}

TEST_P(BlobTableInteTest, TestWithRowIdsSimple) {
    CreateTable();
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    std::vector<std::string> write_cols = {"f0", "f1", "f2"};
    // row0: 0, "a0", "b0"; row1: 1, "a1", "b1" ...
    auto src_array = PrepareBulkData(
        1000,
        [](int32_t i) {
            return std::to_string(i) + ", \"a" + std::to_string(i) + "\", \"b" + std::to_string(i) +
                   "\"";
        },
        fields_);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, /*partition=*/{}, write_cols, {src_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [10, "a10", "b10"],
        [999, "a999", "b999"]
    ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array,
                          /*predicate=*/nullptr,
                          /*row_ranges=*/{Range(999l, 999l), Range(10l, 10l)}));
}

TEST_P(BlobTableInteTest, TestWithRowIdsForMultipleBlobFiles) {
    auto file_format = GetParam();
    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, file_format},
                                                  {Options::TARGET_FILE_SIZE, "1000"},
                                                  {Options::BLOB_TARGET_FILE_SIZE, "80"},
                                                  {Options::BUCKET, "-1"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"},
                                                  {Options::FILE_SYSTEM, "local"}};
    CreateTable(/*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields_);

    // target blob size is 80, therefore, each 4 rows in blob will be in one file
    std::vector<std::string> write_cols = {"f0", "f1", "f2"};
    auto src_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [0, "aaa0", "b0"],
        [1, "aaa1", "b1"],
        [2, "aaa2", "b2"],
        [3, "aaa3", "b3"],
        [4, "aaa4", "b4"],
        [5, "aaa5", "b5"],
        [6, "aaa6", "b6"],
        [7, "aaa7", "b7"],
        [8, "aaa8", "b8"],
        [9, "aaa9", "b9"]
    ])")
            .ValueOrDie());

    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, /*partition=*/{}, write_cols, {src_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    {
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "aaa1", "b1"],
        [8, "aaa8", "b8"]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array,
                              /*predicate=*/nullptr,
                              /*row_ranges=*/{Range(1l, 1l), Range(8l, 8l)}));
    }
    {
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "aaa1", "b1"],
        [5, "aaa5", "b5"]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array,
                              /*predicate=*/nullptr,
                              /*row_ranges=*/{Range(1l, 1l), Range(5l, 5l)}));
    }
    {
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [5, "aaa5", "b5"],
        [6, "aaa6", "b6"],
        [8, "aaa8", "b8"]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array,
                              /*predicate=*/nullptr,
                              /*row_ranges=*/{Range(5l, 6l), Range(8l, 8l)}));
    }
    {
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [5, "aaa5", "b5"],
        [7, "aaa7", "b7"]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array,
                              /*predicate=*/nullptr,
                              /*row_ranges=*/{Range(5l, 5l), Range(7l, 7l)}));
    }
    {
        // f0 0-9 in one file, predicate does not skip any data file
        auto predicate = PredicateBuilder::Equal(/*field_index=*/0, /*field_name=*/"f0",
                                                 FieldType::INT, Literal(5));
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_), R"([
        [1, "aaa1", "b1"],
        [5, "aaa5", "b5"]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array, predicate,
                              /*row_ranges=*/{Range(1l, 1l), Range(5l, 5l)}));
    }
    {
        // test not read blob field
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields_[1]}), R"([
        ["aaa1"],
        ["aaa8"]
    ])")
                .ValueOrDie());
        ASSERT_OK(ScanAndRead(table_path, {"f1"}, expected_array,
                              /*predicate=*/nullptr,
                              /*row_ranges=*/{Range(1l, 1l), Range(8l, 8l)}));
    }
}

TEST_P(BlobTableInteTest, TestAppendTableWriteWithMultipleBlobFields) {
    arrow::FieldVector fields = {
        arrow::field("f0", arrow::utf8()), arrow::field("f1", arrow::int32()),
        BlobUtils::ToArrowField("blob1", true), BlobUtils::ToArrowField("blob2", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},       {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},      {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"}, {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    std::string data_json = R"([
        ["str_0", null, "apple",  "red"],
        ["str_1", 1,    "banana", "yellow"],
        ["str_2", 2,    "cat",    "black"]
    ])";
    auto write_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), data_json).ValueOrDie());

    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {write_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), write_array));
}

TEST_P(BlobTableInteTest, TestAppendWriteWithNullBlob) {
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("blob", true)};

    std::map<std::string, std::string> options = {{Options::MANIFEST_FORMAT, "orc"},
                                                  {Options::FILE_FORMAT, GetParam()},
                                                  {Options::BUCKET, "-1"},
                                                  {Options::FILE_SYSTEM, "local"},
                                                  {Options::ROW_TRACKING_ENABLED, "true"},
                                                  {Options::DATA_EVOLUTION_ENABLED, "true"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // Write: row 0 non-null blob, row 1 null blob, row 2 non-null blob
    std::string data_json = R"([
        [1, "hello"],
        [2, null],
        [3, "world"]
    ])";
    auto write_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), data_json).ValueOrDie());

    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {write_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));
    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), write_array));
}

TEST_P(BlobTableInteTest, TestReadTableWithMultiBlobFields) {
    auto file_format = GetParam();
    if (file_format == "avro") {
        return;
    }
    std::string table_path = paimon::test::GetDataDir() + file_format +
                             "/append_table_with_multi_blob.db/append_table_with_multi_blob";
    std::vector<DataField> read_fields = {
        DataField(5, BlobUtils::ToArrowField("f5")),
        DataField(1, arrow::field("f1", arrow::int32())),
        DataField(2, arrow::field("f2", arrow::int32())),
        DataField(3, arrow::field("f3", arrow::float64())),
        DataField(4, arrow::field("f4", arrow::utf8())),
        SpecialFields::RowId(),
        DataField(6, BlobUtils::ToArrowField("f6")),
    };

    std::shared_ptr<arrow::DataType> arrow_data_type =
        DataField::ConvertDataFieldsToArrowStructType(read_fields);

    auto make_json_row = [&](int32_t i) -> std::string {
        std::string f5_json;
        if (i <= 1) {
            f5_json = "null";
        } else {
            f5_json = "\"" + std::string(1024, static_cast<char>('A' + i)) + "\"";
        }

        std::string f6_json;
        if (i == 0 || i == 2) {
            f6_json = "null";
        } else {
            f6_json = "\"" + std::string(2048, static_cast<char>('a' + i)) + "\"";
        }
        // f1=i, f2=i*10, f3=i+0.5, f4="desc_i", row_id=i
        return "[" + f5_json + ", " + std::to_string(i) + ", " + std::to_string(i * 10) + ", " +
               std::to_string(i + 0.5) + ", \"desc_" + std::to_string(i) + "\", " +
               std::to_string(i) + ", " + f6_json + "]";
    };

    auto build_expected =
        [&](const std::vector<int32_t>& row_indices) -> std::shared_ptr<arrow::StructArray> {
        std::string json_str = "[";
        for (size_t idx = 0; idx < row_indices.size(); idx++) {
            if (idx > 0) {
                json_str += ",";
            }
            json_str += make_json_row(row_indices[idx]);
        }
        json_str += "]";
        return std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow_data_type, json_str).ValueOrDie());
    };

    // Full scan: all 10 rows
    {
        std::vector<int32_t> all_rows(10);
        std::iota(all_rows.begin(), all_rows.end(), 0);
        auto expected_array = build_expected(all_rows);
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              expected_array));
    }

    // Test with row_ranges: select row 0 (both null), row 2 (f5 non-null, f6 null),
    // row 5 (both non-null) to verify null handling under row range filtering.
    {
        auto expected_rr = build_expected({0, 2, 5});
        ASSERT_OK(ScanAndRead(table_path, arrow::schema(arrow_data_type->fields())->field_names(),
                              expected_rr, /*predicate=*/nullptr,
                              /*row_ranges=*/{Range(0l, 0l), Range(2l, 2l), Range(5l, 5l)}));
    }
}

TEST_P(BlobTableInteTest, TestBlobDescriptorField) {
    // Two blob fields configured via BLOB_DESCRIPTOR_FIELD and stored inline as descriptors.
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("b0", true),
                                 BlobUtils::ToArrowField("b1", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},         {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},   {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_DESCRIPTOR_FIELD, "b0,b1"}, {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // Input uses plain raw bytes for readability
    std::string raw_json = R"([
        [1, "image_data_0", "video_data_0"],
        [2, "image_data_1", "video_data_1"],
        [3, "image_data_2", "video_data_2"]
    ])";
    auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array, ConvertRawBlobToDescriptor(raw_array, {"b0", "b1"}));

    // write descriptor array
    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {desc_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // Scan and verify DataFileMeta: all blob fields are inline descriptors, so write_cols is unset.
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    VerifyDataFileMetas(plan, /*expected_file_count=*/1, /*expected_row_counts=*/{3},
                        /*expected_min_seqs=*/{1}, /*expected_max_seqs=*/{1},
                        /*expected_first_row_ids=*/{0},
                        /*expected_write_cols=*/{std::nullopt});

    // Read and resolve descriptors back to raw bytes
    std::map<std::string, std::string> read_options = {};
    ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, schema->field_names(), plan,
                                                /*predicate=*/nullptr, read_options));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(read_struct, {"b0", "b1"}));
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(raw_array));
    ASSERT_TRUE(resolved->Equals(expected_with_rk));

    // Descriptor bytes should be unchanged (inline, not repacked)
    ASSERT_TRUE(read_struct->GetFieldByName("b0")->Equals(desc_array->GetFieldByName("b0")));
    ASSERT_TRUE(read_struct->GetFieldByName("b1")->Equals(desc_array->GetFieldByName("b1")));
}

TEST_P(BlobTableInteTest, TestBlobDescriptorFieldPartialInline) {
    // 4 blob fields: b0,b1 are inline descriptors; b2,b3 are regular blob fields written to
    // .blob files.
    arrow::FieldVector fields = {
        arrow::field("f0", arrow::int32()), BlobUtils::ToArrowField("b0", true),
        BlobUtils::ToArrowField("b1", true), BlobUtils::ToArrowField("b2", true),
        BlobUtils::ToArrowField("b3", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},         {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},   {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_DESCRIPTOR_FIELD, "b0,b1"}, {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // Input uses plain raw bytes:
    // b0: all non-null, b1: has nulls, b2: all non-null, b3: has nulls
    std::string raw_json = R"([
        [1, "img_0", null,    "raw_2_0", "raw_3_0"],
        [2, "img_1", "vid_1", "raw_2_1", null      ],
        [3, "img_2", null,    "raw_2_2", "raw_3_2" ]
    ])";
    auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array,
                         ConvertRawBlobToDescriptor(raw_array, {"b0", "b1", "b2", "b3"}));

    // write: b0,b1 as descriptor bytes; b2,b3 as raw bytes (paimon writes them to .blob files)
    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {desc_array}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // Scan and verify DataFileMeta: b2,b3 go to .blob files, "f0", "b0", "b1" go to main files.
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    VerifyDataFileMetas(plan, /*expected_file_count=*/3, /*expected_row_counts=*/{3, 3, 3},
                        /*expected_min_seqs=*/{1, 1, 1}, /*expected_max_seqs=*/{1, 1, 1},
                        /*expected_first_row_ids=*/{0, 0, 0},
                        /*expected_write_cols=*/
                        {std::vector<std::string>{"f0", "b0", "b1"}, std::vector<std::string>{"b2"},
                         std::vector<std::string>{"b3"}});

    std::map<std::string, std::string> read_options = {{Options::BLOB_AS_DESCRIPTOR, "true"}};
    ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, schema->field_names(), plan,
                                                /*predicate=*/nullptr, read_options));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);

    // b0,b1 inline descriptor (not repacked), should match input
    ASSERT_TRUE(read_struct->GetFieldByName("b0")->Equals(desc_array->GetFieldByName("b0")));
    ASSERT_TRUE(read_struct->GetFieldByName("b1")->Equals(desc_array->GetFieldByName("b1")));

    // Resolve b0,b1 descriptors back to raw bytes, then compare full struct
    ASSERT_OK_AND_ASSIGN(auto resolved,
                         ConvertDescriptorToRawBlob(read_struct, {"b0", "b1", "b2", "b3"}));
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(raw_array));
    ASSERT_TRUE(resolved->Equals(expected_with_rk));
}

TEST_P(BlobTableInteTest, TestBlobDescriptorMultiCommitAndShuffledReadSchema) {
    // Multiple write+commit rounds with a shuffled read schema: b3, b2, b1, b0, f0.
    arrow::FieldVector fields = {
        arrow::field("f0", arrow::int32()), BlobUtils::ToArrowField("b0", true),
        BlobUtils::ToArrowField("b1", true), BlobUtils::ToArrowField("b2", true),
        BlobUtils::ToArrowField("b3", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},         {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},   {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_DESCRIPTOR_FIELD, "b0,b1"}, {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
    auto schema = arrow::schema(fields);

    // --- First write+commit ---
    std::string raw_json_1 = R"([
        [1, "img_0", null,    "raw_2_0", "raw_3_0"],
        [2, "img_1", "vid_1", "raw_2_1", null      ]
    ])";
    auto raw_array_1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json_1).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array_1, ConvertRawBlobToDescriptor(raw_array_1, {"b0", "b1"}));
    ASSERT_OK_AND_ASSIGN(auto commit_msgs_1,
                         WriteArray(table_path, {}, schema->field_names(), {desc_array_1}));
    ASSERT_OK(Commit(table_path, commit_msgs_1));

    // --- Second write+commit ---
    std::string raw_json_2 = R"([
        [3, "img_2", "vid_2", "raw_2_2", "raw_3_2"],
        [4, null,    "vid_3", "raw_2_3", "raw_3_3"]
    ])";
    auto raw_array_2 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json_2).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array_2, ConvertRawBlobToDescriptor(raw_array_2, {"b0", "b1"}));
    ASSERT_OK_AND_ASSIGN(auto commit_msgs_2,
                         WriteArray(table_path, {}, schema->field_names(), {desc_array_2}));
    ASSERT_OK(Commit(table_path, commit_msgs_2));

    // --- Third write+commit ---
    std::string raw_json_3 = R"([
        [5, "img_4", null,    "raw_2_4", null     ],
        [6, "img_5", "vid_5", null,      "raw_3_5"]
    ])";
    auto raw_array_3 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json_3).ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto desc_array_3, ConvertRawBlobToDescriptor(raw_array_3, {"b0", "b1"}));
    ASSERT_OK_AND_ASSIGN(auto commit_msgs_3,
                         WriteArray(table_path, {}, schema->field_names(), {desc_array_3}));
    ASSERT_OK(Commit(table_path, commit_msgs_3));

    // test read
    {
        // --- Read with shuffled schema: b3, b2, b1, b0, f0 ---
        std::vector<std::string> shuffled_read_schema = {"b3", "b2", "b1", "b0", "f0"};
        ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));

        std::map<std::string, std::string> read_options = {{Options::BLOB_AS_DESCRIPTOR, "false"}};
        ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, shuffled_read_schema, plan,
                                                    /*predicate=*/nullptr, read_options));
        ASSERT_TRUE(result.chunked_array);
        auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
        auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);

        // Build expected array in shuffled order from all 3 batches
        arrow::FieldVector shuffled_fields = {
            BlobUtils::ToArrowField("b3", true), BlobUtils::ToArrowField("b2", true),
            BlobUtils::ToArrowField("b1", true), BlobUtils::ToArrowField("b0", true),
            arrow::field("f0", arrow::int32())};
        std::string expected_json = R"([
        ["raw_3_0", "raw_2_0", null,    "img_0", 1],
        [null,      "raw_2_1", "vid_1", "img_1", 2],
        ["raw_3_2", "raw_2_2", "vid_2", "img_2", 3],
        ["raw_3_3", "raw_2_3", "vid_3", null,    4],
        [null,      "raw_2_4", null,    "img_4", 5],
        ["raw_3_5", null,      "vid_5", "img_5", 6]
    ])";
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(shuffled_fields),
                                                      expected_json)
                .ValueOrDie());

        // Resolve descriptors (b0, b1 are descriptor fields) back to raw bytes
        ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(read_struct, {"b0", "b1"}));
        ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_array));
        ASSERT_TRUE(resolved->Equals(expected_with_rk));
    }
    {
        // test scan and read with GlobalIndexResult
        std::vector<std::string> shuffled_read_schema = {"b3", "b2", "b1", "b0", "f0"};
        ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path, /*predicate=*/nullptr,
                                                  /*row_ranges=*/{Range(1, 3), Range(5, 5)}));
        std::map<std::string, std::string> read_options = {{Options::BLOB_AS_DESCRIPTOR, "false"}};
        ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, shuffled_read_schema, plan,
                                                    /*predicate=*/nullptr, read_options));
        ASSERT_TRUE(result.chunked_array);
        auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
        auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);

        // Build expected array in shuffled order from all 3 batches
        arrow::FieldVector shuffled_fields = {
            BlobUtils::ToArrowField("b3", true), BlobUtils::ToArrowField("b2", true),
            BlobUtils::ToArrowField("b1", true), BlobUtils::ToArrowField("b0", true),
            arrow::field("f0", arrow::int32())};
        std::string expected_json = R"([
        [null,      "raw_2_1", "vid_1", "img_1", 2],
        ["raw_3_2", "raw_2_2", "vid_2", "img_2", 3],
        ["raw_3_3", "raw_2_3", "vid_3", null,    4],
        ["raw_3_5", null,      "vid_5", "img_5", 6]
    ])";
        auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(shuffled_fields),
                                                      expected_json)
                .ValueOrDie());

        // Resolve descriptors (b0, b1 are descriptor fields) back to raw bytes
        ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(read_struct, {"b0", "b1"}));
        ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_array));
        ASSERT_TRUE(resolved->Equals(expected_with_rk));
    }
}

// The shared-shredding map is read from one main data file while the blob payload is read from a
// separate blob file with the same row-id range.
TEST_P(BlobTableInteTest, TestSharedShreddingWithBlobDataEvolution) {
    if (GetParam() == "avro") {
        return;
    }

    arrow::FieldVector fields = {
        arrow::field("id", arrow::int32()),
        arrow::field("tags", arrow::map(arrow::utf8(), arrow::int64())),
        BlobUtils::ToArrowField("payload"),
    };
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, GetParam()},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::FILE_SYSTEM, "local"},
        {"fields.tags.map.storage-layout", "shared-shredding"},
        {"fields.tags.map.shared-shredding.max-columns", "1"},
    };
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    auto map_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[0], fields[1]}),
                                                  R"([
                [1, [["a", 10], ["z", 11]]],
                [2, [["a", 20]]],
                [3, null]
            ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto map_msgs, WriteArray(table_path, {}, {"id", "tags"}, {map_array}));
    ASSERT_OK(Commit(table_path, map_msgs));

    auto blob_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[0], fields[2]}),
                                                  R"([
                [1, "payload-1"],
                [2, "payload-2"],
                [3, "payload-3"]
            ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto blob_msgs,
                         WriteArray(table_path, {}, {"id", "payload"}, {blob_array}));
    SetFirstRowId(0, blob_msgs);
    ASSERT_OK(Commit(table_path, blob_msgs));

    auto expected = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
                                                  R"([
                [1, [["a", 10], ["z", 11]], "payload-1"],
                [2, [["a", 20]], "payload-2"],
                [3, null, "payload-3"]
            ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, arrow::schema(fields)->field_names(), expected));
}

// Two independent shared-shredding map columns are written into different main files.
TEST_P(BlobTableInteTest, TestMultipleSharedShreddingMapsWithBlobDataEvolution) {
    if (GetParam() == "avro") {
        return;
    }

    arrow::FieldVector fields = {
        arrow::field("id", arrow::int32()),
        arrow::field("f0", arrow::map(arrow::utf8(), arrow::int64())),
        arrow::field("f1", arrow::map(arrow::utf8(), arrow::utf8())),
        BlobUtils::ToArrowField("payload"),
    };
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, GetParam()},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::FILE_SYSTEM, "local"},
        {"fields.f0.map.storage-layout", "shared-shredding"},
        {"fields.f0.map.shared-shredding.max-columns", "1"},
        {"fields.f1.map.storage-layout", "shared-shredding"},
        {"fields.f1.map.shared-shredding.max-columns", "1"},
    };
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    auto f0_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[0], fields[1]}),
                                                  R"([
                [1, [["a", 10], ["z", 11]]],
                [2, [["a", 20]]],
                [3, null]
            ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto f0_msgs, WriteArray(table_path, {}, {"id", "f0"}, {f0_array}));
    ASSERT_OK(Commit(table_path, f0_msgs));

    auto f1_blob_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[2], fields[3]}),
                                                  R"([
                [[["b", "red"], ["y", "blue"]], "payload-1"],
                [[["b", "green"]], "payload-2"],
                [[], "payload-3"]
            ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto f1_blob_msgs,
                         WriteArray(table_path, {}, {"f1", "payload"}, {f1_blob_array}));
    SetFirstRowId(0, f1_blob_msgs);
    ASSERT_OK(Commit(table_path, f1_blob_msgs));

    auto expected = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
                                                  R"([
                [1, [["a", 10], ["z", 11]], [["b", "red"], ["y", "blue"]], "payload-1"],
                [2, [["a", 20]], [["b", "green"]], "payload-2"],
                [3, null, [], "payload-3"]
            ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, arrow::schema(fields)->field_names(), expected));
}

// A newer partial data file rewrites only the shared-shredding map for the same row-id range.
TEST_P(BlobTableInteTest, TestSharedShreddingMapOverrideWithBlobDataEvolution) {
    if (GetParam() == "avro") {
        return;
    }

    arrow::FieldVector fields = {
        arrow::field("id", arrow::int32()),
        arrow::field("tags", arrow::map(arrow::utf8(), arrow::int64())),
        BlobUtils::ToArrowField("payload"),
    };
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, GetParam()},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::FILE_SYSTEM, "local"},
        {"fields.tags.map.storage-layout", "shared-shredding"},
        {"fields.tags.map.shared-shredding.max-columns", "1"},
    };
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    auto old_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
                                                  R"([
                [1, [["a", 10], ["z", 11]], "payload-1"],
                [2, [["a", 20]], "payload-2"],
                [3, [["a", 30]], "payload-3"]
            ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(
        auto old_msgs,
        WriteArray(table_path, {}, arrow::schema(fields)->field_names(), {old_array}));
    ASSERT_OK(Commit(table_path, old_msgs));

    auto new_map_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[1]}),
                                                  R"([
                [[["a", 100], ["z", 101]]],
                [null],
                [[]]
            ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto new_map_msgs, WriteArray(table_path, {}, {"tags"}, {new_map_array}));
    SetFirstRowId(0, new_map_msgs);
    ASSERT_OK(Commit(table_path, new_map_msgs));

    auto expected = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
                                                  R"([
                [1, [["a", 100], ["z", 101]], "payload-1"],
                [2, null, "payload-2"],
                [3, [], "payload-3"]
            ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, arrow::schema(fields)->field_names(), expected));
}

TEST_P(BlobTableInteTest, TestOrcMapStorageLayoutEvolutionWithBlobDataEvolution) {
    if (GetParam() != "orc") {
        return;
    }

    arrow::FieldVector fields = {
        arrow::field("id", arrow::int32()),
        arrow::field("tags", arrow::map(arrow::utf8(), arrow::utf8())),
        BlobUtils::ToArrowField("payload"),
    };
    std::map<std::string, std::string> options_v0 = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, "orc"},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::FILE_SYSTEM, "local"},
        {"fields.tags.map.storage-layout", "default"},
        {"orc.read.enable-lazy-decoding", "true"},
        {"orc.dictionary-key-size-threshold", "1"},
    };
    CreateTable(fields, /*partition_keys=*/{}, options_v0);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    auto array_v0 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
                                                  R"([
                [1, [["a", "red"], ["z", "blue"]], "payload-1"],
                [2, [["a", "red"], ["z", "green"]], "payload-2"]
            ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(
        auto msgs_v0, WriteArray(table_path, {}, arrow::schema(fields)->field_names(), {array_v0}));
    ASSERT_OK(Commit(table_path, msgs_v0));

    std::map<std::string, std::string> options_v1 = options_v0;
    options_v1["fields.tags.map.storage-layout"] = "shared-shredding";
    options_v1["fields.tags.map.shared-shredding.max-columns"] = "1";
    ASSERT_OK(WriteNextSchema(
        table_path, {DataField(0, fields[0]), DataField(1, fields[1]), DataField(2, fields[2])},
        /*highest_field_id=*/2, options_v1));

    auto array_v1 = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
                                                  R"([
                [3, [["a", "red"], ["z", "yellow"]], "payload-3"],
                [4, [["a", "red"]], "payload-4"]
            ])")
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(
        auto msgs_v1, WriteArray(table_path, {}, arrow::schema(fields)->field_names(), {array_v1}));
    ASSERT_OK(Commit(table_path, msgs_v1));

    auto expected = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
                                                  R"([
                [1, [["a", "red"], ["z", "blue"]], "payload-1"],
                [2, [["a", "red"], ["z", "green"]], "payload-2"],
                [3, [["a", "red"], ["z", "yellow"]], "payload-3"],
                [4, [["a", "red"]], "payload-4"]
            ])")
            .ValueOrDie());
    ASSERT_OK(ScanAndRead(table_path, arrow::schema(fields)->field_names(), expected));
}

TEST_P(BlobTableInteTest, TestDataEvolutionWithBlobDescriptorField) {
    // Test DataEvolution (split-column write) combined with blob descriptor fields.
    // Schema: f0(int32), b0/b1(blob descriptor inline), b2/b3(blob).
    // Commit 1: file A writes (f0, b2, b3)
    // Commit 2: file B writes (f0, b0, b1) with SetFirstRowId(0)
    // -> merges with commit 1
    // Commit 3: file A writes (f0, b0, b1, b3)
    // Commit 4: file B writes (b0, b1, b3) with SetFirstRowId(3)
    // -> merges with commit 3
    arrow::FieldVector fields = {
        arrow::field("f0", arrow::int32()), BlobUtils::ToArrowField("b0", true),
        BlobUtils::ToArrowField("b1", true), BlobUtils::ToArrowField("b2", true),
        BlobUtils::ToArrowField("b3", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},         {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},   {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_DESCRIPTOR_FIELD, "b0,b1"}, {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // --- Commit 1: file A (f0, b2, b3), Commit 2: file B (f0, b0, b1) SetFirstRowId(0) ---
    std::string file_a1_json = R"([
        [1, "raw_2_0", "raw_3_0"],
        [2, "raw_2_1", null     ],
        [3, null,      "raw_3_2"]
    ])";
    arrow::FieldVector file_a1_fields = {fields[0], fields[3], fields[4]};
    auto file_a1_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(file_a1_fields), file_a1_json)
            .ValueOrDie());

    std::string file_b1_json = R"([
        [1, "img_0", "vid_0"],
        [2, "img_1", null   ],
        [3, "img_2", "vid_2"]
    ])";
    arrow::FieldVector file_b1_fields = {fields[0], fields[1], fields[2]};
    auto file_b1_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(file_b1_fields), file_b1_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto file_b1_desc,
                         ConvertRawBlobToDescriptor(file_b1_array, {"b0", "b1"}));

    ASSERT_OK_AND_ASSIGN(auto commit_msgs_a1,
                         WriteArray(table_path, {}, {"f0", "b2", "b3"}, {file_a1_array}));
    ASSERT_OK(Commit(table_path, commit_msgs_a1));

    ASSERT_OK_AND_ASSIGN(auto commit_msgs_b1,
                         WriteArray(table_path, {}, {"f0", "b0", "b1"}, {file_b1_desc}));
    SetFirstRowId(0, commit_msgs_b1);
    ASSERT_OK(Commit(table_path, commit_msgs_b1));

    // --- Commit 3: file A (f0, b0, b1, b3), Commit 4: file B (b0, b1, b3) SetFirstRowId(3) ---
    // Duplicate cols b0, b1, b3: file B (commit 4, newer) takes precedence.
    std::string file_a2_json = R"([
        [4, "img_3_old", "vid_3_old", "raw_3_3_old"],
        [5, null,        "vid_4_old", "raw_3_4_old"],
        [6, "img_5_old", null,        null         ]
    ])";
    arrow::FieldVector file_a2_fields = {fields[0], fields[1], fields[2], fields[4]};
    auto file_a2_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(file_a2_fields), file_a2_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto file_a2_desc,
                         ConvertRawBlobToDescriptor(file_a2_array, {"b0", "b1"}));

    std::string file_b2_json = R"([
        ["img_3", "vid_3", "raw_3_3"],
        [null,    "vid_4", "raw_3_4"],
        ["img_5", null,    null     ]
    ])";
    arrow::FieldVector file_b2_fields = {fields[1], fields[2], fields[4]};
    auto file_b2_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(file_b2_fields), file_b2_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto file_b2_desc,
                         ConvertRawBlobToDescriptor(file_b2_array, {"b0", "b1"}));

    ASSERT_OK_AND_ASSIGN(auto commit_msgs_a2,
                         WriteArray(table_path, {}, {"f0", "b0", "b1", "b3"}, {file_a2_desc}));
    ASSERT_OK(Commit(table_path, commit_msgs_a2));

    ASSERT_OK_AND_ASSIGN(auto commit_msgs_b2,
                         WriteArray(table_path, {}, {"b0", "b1", "b3"}, {file_b2_desc}));
    SetFirstRowId(3, commit_msgs_b2);
    ASSERT_OK(Commit(table_path, commit_msgs_b2));

    // --- Read all data with full schema ---
    std::vector<std::string> read_schema = {"f0", "b0", "b1", "b2", "b3"};
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));

    std::map<std::string, std::string> read_options = {{Options::BLOB_AS_DESCRIPTOR, "false"}};
    ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, read_schema, plan,
                                                /*predicate=*/nullptr, read_options));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_EQ(read_struct->length(), 6);

    // Expected: round1 all columns present; round2 b2=null, b0/b1/b3 from file B (newer)
    std::string expected_json = R"([
        [1, "img_0", "vid_0", "raw_2_0", "raw_3_0"],
        [2, "img_1", null,    "raw_2_1", null      ],
        [3, "img_2", "vid_2", null,      "raw_3_2" ],
        [4, "img_3", "vid_3", null,      "raw_3_3" ],
        [5, null,    "vid_4", null,      "raw_3_4" ],
        [6, "img_5", null,    null,      null      ]
    ])";
    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_json)
            .ValueOrDie());

    // Resolve descriptors back to raw bytes
    ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(read_struct, {"b0", "b1"}));
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_array));
    ASSERT_TRUE(resolved->type()->Equals(expected_with_rk->type()));
    ASSERT_TRUE(resolved->Equals(expected_with_rk));
}

TEST_P(BlobTableInteTest, TestBlobDescriptorFieldWriteRawBytesDirectly) {
    // Similar to TestBlobDescriptorField but writes raw bytes directly without converting to
    // descriptor first. Descriptor fields reject values without the descriptor magic header.
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("b0", true),
                                 BlobUtils::ToArrowField("b1", true)};

    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},         {Options::FILE_FORMAT, GetParam()},
        {Options::TARGET_FILE_SIZE, "700"},        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},   {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_DESCRIPTOR_FIELD, "b0,b1"}, {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // Write raw bytes directly (no ConvertRawBlobToDescriptor)
    std::string raw_json = R"([
        [1, "image_data_0", "video_data_0"],
        [2, "image_data_1", "video_data_1"],
        [3, "image_data_2", "video_data_2"]
    ])";
    auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());

    auto schema = arrow::schema(fields);
    ASSERT_NOK_WITH_MSG(WriteArray(table_path, {}, schema->field_names(), {raw_array}),
                        "BLOB inline field b0 require values to be set as corresponding type.");
}

TEST_P(BlobTableInteTest, TestBlobViewFieldWithUpstreamTable) {
    auto file_format = GetParam();
    if (file_format == "avro") {
        return;
    }

    const std::string upstream_db_name = "append_table_with_multi_blob";
    const std::string upstream_table_name = "append_table_with_multi_blob";
    std::string src_db_path = paimon::test::GetDataDir() + file_format + "/" + upstream_db_name +
                              ".db/" + upstream_table_name;
    std::string dst_db_path =
        PathUtil::JoinPath(dir_->Str(), upstream_db_name + ".db/" + upstream_table_name);
    ASSERT_TRUE(TestUtil::CopyDirectory(src_db_path, dst_db_path));

    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("view", true)};
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, file_format},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_VIEW_FIELD, "view"},
        {Options::BLOB_VIEW_UPSTREAM_WAREHOUSE, dir_->Str()},
        {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // src array
    Identifier upstream_identifier(upstream_db_name, upstream_table_name);
    arrow::LargeBinaryBuilder view_builder;
    for (int32_t i = 0; i < 8; ++i) {
        if (i < 6) {
            BlobViewStruct view_struct(upstream_identifier, /*field_id=*/6,
                                       /*row_id=*/static_cast<int64_t>(i));
            auto serialized = view_struct.Serialize(pool_);
            ASSERT_TRUE(view_builder
                            .Append(reinterpret_cast<const uint8_t*>(serialized->data()),
                                    serialized->size())
                            .ok());
        } else {
            ASSERT_TRUE(view_builder.AppendNull().ok());
        }
    }
    std::shared_ptr<arrow::Array> write_view_array;
    ASSERT_TRUE(view_builder.Finish(&write_view_array).ok());
    auto write_f0_array = arrow::ipc::internal::json::ArrayFromJSON(
                              arrow::int32(), R"([100,101,102,103,104,105,106,107])")
                              .ValueOrDie();
    auto write_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::StructArray::Make(arrow::ArrayVector({write_f0_array, write_view_array}),
                                 std::vector<std::string>({"f0", "view"}))
            .ValueOrDie());

    // write & commit
    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {write_struct}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    std::string padding_b(2048, 'b');
    std::string padding_d(2048, 'd');
    std::string padding_e(2048, 'e');
    std::string padding_f(2048, 'f');

    // scan & read
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    ASSERT_OK_AND_ASSIGN(auto result,
                         ReadTable(table_path, schema->field_names(), plan, /*predicate=*/nullptr));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_EQ(read_struct->length(), 8);
    ASSERT_OK_AND_ASSIGN(auto result_array, ConvertDescriptorToRawBlob(read_struct, {"view"}));

    // clang-format off
    std::string expected_json = R"([
[100, null],
[101, ")" + padding_b + R"("],
[102, null],
[103, ")" + padding_d + R"("],
[104, ")" + padding_e + R"("],
[105, ")" + padding_f + R"("],
[106, null],
[107, null]
])";
    // clang-format on
    auto expected_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_struct));

    ASSERT_TRUE(result_array->Equals(expected_with_rk))
        << "result_array:" << result_array->ToString() << std::endl
        << "expected:" << expected_with_rk->ToString();

    // Sub-case 1: scan with row_ranges.
    {
        ASSERT_OK_AND_ASSIGN(auto range_plan, ScanTable(table_path, /*predicate=*/nullptr,
                                                        /*row_ranges=*/{Range(1, 3), Range(5, 5)}));
        ASSERT_OK_AND_ASSIGN(auto range_result, ReadTable(table_path, schema->field_names(),
                                                          range_plan, /*predicate=*/nullptr));
        ASSERT_TRUE(range_result.chunked_array);
        auto range_concat = arrow::Concatenate(range_result.chunked_array->chunks()).ValueOrDie();
        auto range_struct = std::dynamic_pointer_cast<arrow::StructArray>(range_concat);
        ASSERT_EQ(range_struct->length(), 4);
        ASSERT_OK_AND_ASSIGN(auto range_resolved,
                             ConvertDescriptorToRawBlob(range_struct, {"view"}));

        // clang-format off
        std::string range_json = R"([
[101, ")" + padding_b + R"("],
[102, null],
[103, ")" + padding_d + R"("],
[105, ")" + padding_f + R"("]
])";
        // clang-format on
        auto range_expected_struct = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), range_json)
                .ValueOrDie());
        ASSERT_OK_AND_ASSIGN(auto range_expected_with_rk,
                             PrependRowKindColumn(range_expected_struct));
        ASSERT_TRUE(range_resolved->Equals(range_expected_with_rk))
            << "range_resolved:" << range_resolved->ToString() << std::endl
            << "expected:" << range_expected_with_rk->ToString();
    }

    // Sub-case 2: scan with predicate (f0 > 102), data evolution split read will ignore format push
    // down
    {
        auto predicate = PredicateBuilder::GreaterThan(/*field_index=*/0, /*field_name=*/"f0",
                                                       FieldType::INT, Literal(102));
        ASSERT_OK_AND_ASSIGN(auto pred_plan, ScanTable(table_path, predicate, /*row_ranges=*/{}));
        ASSERT_OK_AND_ASSIGN(auto pred_result,
                             ReadTable(table_path, schema->field_names(), pred_plan, predicate));
        ASSERT_TRUE(pred_result.chunked_array);
        auto pred_concat = arrow::Concatenate(pred_result.chunked_array->chunks()).ValueOrDie();
        auto pred_struct = std::dynamic_pointer_cast<arrow::StructArray>(pred_concat);
        ASSERT_EQ(pred_struct->length(), 8);
        ASSERT_OK_AND_ASSIGN(auto pred_resolved, ConvertDescriptorToRawBlob(pred_struct, {"view"}));

        // clang-format off
        std::string pred_json = R"([
[100, null],
[101, ")" + padding_b + R"("],
[102, null],
[103, ")" + padding_d + R"("],
[104, ")" + padding_e + R"("],
[105, ")" + padding_f + R"("],
[106, null],
[107, null]
])";
        // clang-format on
        auto pred_expected_struct = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), pred_json)
                .ValueOrDie());
        ASSERT_OK_AND_ASSIGN(auto pred_expected_with_rk,
                             PrependRowKindColumn(pred_expected_struct));
        ASSERT_TRUE(pred_resolved->Equals(pred_expected_with_rk))
            << "pred_resolved:" << pred_resolved->ToString() << std::endl
            << "expected:" << pred_expected_with_rk->ToString();
    }
}

TEST_P(BlobTableInteTest, TestForwardBlobViewReference) {
    auto file_format = GetParam();
    if (file_format == "avro") {
        return;
    }

    // Forward blob view references between two blob-view tables: read the source table with
    // resolve dynamically disabled, write the preserved BlobViewStruct bytes into the target
    // table, then verify the target still stores the original upstream references and a
    // default read resolves them to the actual upstream blob values.
    const std::string upstream_db_name = "append_table_with_multi_blob";
    const std::string upstream_table_name = "append_table_with_multi_blob";
    std::string src_db_path = paimon::test::GetDataDir() + file_format + "/" + upstream_db_name +
                              ".db/" + upstream_table_name;
    std::string dst_db_path =
        PathUtil::JoinPath(dir_->Str(), upstream_db_name + ".db/" + upstream_table_name);
    ASSERT_TRUE(TestUtil::CopyDirectory(src_db_path, dst_db_path));

    // The source table has no upstream warehouse configured: only a read with resolve
    // dynamically disabled can succeed on it.
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("view", true)};
    std::map<std::string, std::string> source_options = {{Options::MANIFEST_FORMAT, "orc"},
                                                         {Options::FILE_FORMAT, file_format},
                                                         {Options::BUCKET, "-1"},
                                                         {Options::ROW_TRACKING_ENABLED, "true"},
                                                         {Options::DATA_EVOLUTION_ENABLED, "true"},
                                                         {Options::BLOB_VIEW_FIELD, "view"},
                                                         {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, source_options);
    std::string source_table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // The target table configures the upstream warehouse for resolving forwarded references.
    std::map<std::string, std::string> target_options = source_options;
    target_options[Options::BLOB_VIEW_UPSTREAM_WAREHOUSE] = dir_->Str();
    auto schema = arrow::schema(fields);
    ::ArrowSchema c_target_schema;
    ASSERT_TRUE(arrow::ExportSchema(*schema, &c_target_schema).ok());
    ASSERT_OK_AND_ASSIGN(auto catalog, Catalog::Create(dir_->Str(), {}));
    ASSERT_OK(catalog->CreateTable(Identifier("foo", "bar_forward"), &c_target_schema,
                                   /*partition_keys=*/{}, /*primary_keys=*/{}, target_options,
                                   /*ignore_if_exists=*/false));
    std::string target_table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar_forward");

    // src array
    Identifier upstream_identifier(upstream_db_name, upstream_table_name);
    arrow::LargeBinaryBuilder view_builder;
    for (int32_t i = 0; i < 8; ++i) {
        if (i < 6) {
            BlobViewStruct view_struct(upstream_identifier, /*field_id=*/6,
                                       /*row_id=*/static_cast<int64_t>(i));
            auto serialized = view_struct.Serialize(pool_);
            ASSERT_TRUE(view_builder
                            .Append(reinterpret_cast<const uint8_t*>(serialized->data()),
                                    serialized->size())
                            .ok());
        } else {
            ASSERT_TRUE(view_builder.AppendNull().ok());
        }
    }
    std::shared_ptr<arrow::Array> write_view_array;
    ASSERT_TRUE(view_builder.Finish(&write_view_array).ok());
    auto write_f0_array = arrow::ipc::internal::json::ArrayFromJSON(
                              arrow::int32(), R"([100,101,102,103,104,105,106,107])")
                              .ValueOrDie();
    auto write_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::StructArray::Make(arrow::ArrayVector({write_f0_array, write_view_array}),
                                 std::vector<std::string>({"f0", "view"}))
            .ValueOrDie());

    // write & commit into the source table
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(source_table_path, {}, schema->field_names(), {write_struct}));
    ASSERT_OK(Commit(source_table_path, commit_msgs));

    // A default read fails on the missing upstream warehouse: the pass-through below is
    // enabled by the dynamic option alone.
    ASSERT_OK_AND_ASSIGN(auto source_plan, ScanTable(source_table_path));
    ASSERT_NOK_WITH_MSG(
        ReadTable(source_table_path, schema->field_names(), source_plan, /*predicate=*/nullptr),
        "BLOB_VIEW_UPSTREAM_WAREHOUSE");

    ASSERT_OK_AND_ASSIGN(
        auto source_result,
        ReadTable(source_table_path, schema->field_names(), source_plan, /*predicate=*/nullptr,
                  {{Options::BLOB_VIEW_RESOLVE_ENABLED, "false"}}));
    ASSERT_TRUE(source_result.chunked_array);
    auto source_concat = arrow::Concatenate(source_result.chunked_array->chunks()).ValueOrDie();
    auto source_struct = std::dynamic_pointer_cast<arrow::StructArray>(source_concat);
    ASSERT_EQ(source_struct->length(), 8);
    auto forward_f0_array = source_struct->GetFieldByName("f0");
    ASSERT_TRUE(forward_f0_array);
    auto forward_view_array = source_struct->GetFieldByName("view");
    ASSERT_TRUE(forward_view_array);
    ASSERT_TRUE(forward_view_array->Equals(write_view_array))
        << "source view:" << forward_view_array->ToString() << std::endl
        << "written view:" << write_view_array->ToString();

    // Forward the preserved references into the target blob-view table.
    auto forward_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::StructArray::Make(arrow::ArrayVector({forward_f0_array, forward_view_array}),
                                 std::vector<std::string>({"f0", "view"}))
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(
        auto forward_commit_msgs,
        WriteArray(target_table_path, {}, schema->field_names(), {forward_struct}));
    ASSERT_OK(Commit(target_table_path, forward_commit_msgs));

    // The target table stores the original upstream references byte-identically.
    ASSERT_OK_AND_ASSIGN(auto target_plan, ScanTable(target_table_path));
    ASSERT_OK_AND_ASSIGN(
        auto raw_target_result,
        ReadTable(target_table_path, schema->field_names(), target_plan, /*predicate=*/nullptr,
                  {{Options::BLOB_VIEW_RESOLVE_ENABLED, "false"}}));
    ASSERT_TRUE(raw_target_result.chunked_array);
    auto raw_target_concat =
        arrow::Concatenate(raw_target_result.chunked_array->chunks()).ValueOrDie();
    auto raw_target_struct = std::dynamic_pointer_cast<arrow::StructArray>(raw_target_concat);
    ASSERT_EQ(raw_target_struct->length(), 8);
    auto raw_target_view_array = raw_target_struct->GetFieldByName("view");
    ASSERT_TRUE(raw_target_view_array);
    ASSERT_TRUE(raw_target_view_array->Equals(write_view_array))
        << "target view:" << raw_target_view_array->ToString() << std::endl
        << "written view:" << write_view_array->ToString();

    // A default read of the target table resolves to the actual upstream blob values.
    ASSERT_OK_AND_ASSIGN(auto resolved_result,
                         ReadTable(target_table_path, schema->field_names(), target_plan,
                                   /*predicate=*/nullptr));
    ASSERT_TRUE(resolved_result.chunked_array);
    auto resolved_concat = arrow::Concatenate(resolved_result.chunked_array->chunks()).ValueOrDie();
    auto resolved_struct = std::dynamic_pointer_cast<arrow::StructArray>(resolved_concat);
    ASSERT_EQ(resolved_struct->length(), 8);
    ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(resolved_struct, {"view"}));

    std::string padding_b(2048, 'b');
    std::string padding_d(2048, 'd');
    std::string padding_e(2048, 'e');
    std::string padding_f(2048, 'f');
    // clang-format off
    std::string expected_json = R"([
[100, null],
[101, ")" + padding_b + R"("],
[102, null],
[103, ")" + padding_d + R"("],
[104, ")" + padding_e + R"("],
[105, ")" + padding_f + R"("],
[106, null],
[107, null]
])";
    // clang-format on
    auto expected_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_struct));
    ASSERT_TRUE(resolved->Equals(expected_with_rk))
        << "resolved:" << resolved->ToString() << std::endl
        << "expected:" << expected_with_rk->ToString();
}

TEST_P(BlobTableInteTest, TestBlobViewFieldWithUpstreamDescriptorBlob) {
    auto file_format = GetParam();
    // Upstream table has two blob descriptor fields. The downstream view references cells from
    // both b0 (field_id=1) and b1 (field_id=2).
    const std::string upstream_db_name = "upstream_two_blob";
    const std::string upstream_table_name = "upstream_two_blob";
    arrow::FieldVector upstream_fields = {arrow::field("f0", arrow::int32()),
                                          BlobUtils::ToArrowField("b0", true),
                                          BlobUtils::ToArrowField("b1", true)};
    auto upstream_schema = arrow::schema(upstream_fields);
    std::map<std::string, std::string> upstream_options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, file_format},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_DESCRIPTOR_FIELD, "b0,b1"},
        {Options::FILE_SYSTEM, "local"}};

    ::ArrowSchema upstream_c_schema;
    ASSERT_TRUE(arrow::ExportSchema(*upstream_schema, &upstream_c_schema).ok());
    ASSERT_OK_AND_ASSIGN(auto upstream_catalog,
                         Catalog::Create(dir_->Str(), {{Options::FILE_SYSTEM, "local"}}));
    ASSERT_OK(upstream_catalog->CreateDatabase(upstream_db_name, {}, /*ignore_if_exists=*/true));
    ASSERT_OK(upstream_catalog->CreateTable(
        Identifier(upstream_db_name, upstream_table_name), &upstream_c_schema,
        /*partition_keys=*/{}, /*primary_keys=*/{}, upstream_options,
        /*ignore_if_exists=*/false));
    std::string upstream_table_path =
        PathUtil::JoinPath(dir_->Str(), upstream_db_name + ".db/" + upstream_table_name);

    // Write 4 rows of b0/b1 data into the upstream table.
    std::string upstream_raw_json = R"([
[0, "b0_data_0", "b1_data_0"],
[1, "b0_data_1", "b1_data_1"],
[2, "b0_data_2", "b1_data_2"],
[3, "b0_data_3", "b1_data_3"]
])";
    auto upstream_raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(upstream_fields),
                                                  upstream_raw_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto upstream_desc_array,
                         ConvertRawBlobToDescriptor(upstream_raw_array, {"b0", "b1"}));
    ASSERT_OK_AND_ASSIGN(
        auto upstream_commit_msgs,
        WriteArray(upstream_table_path, {}, upstream_schema->field_names(), {upstream_desc_array}));
    ASSERT_OK(Commit(upstream_table_path, upstream_commit_msgs));

    // Create the downstream blob-view table that references both b0 and b1 of the upstream table.
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("view", true)};
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, file_format},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_VIEW_FIELD, "view"},
        {Options::BLOB_VIEW_UPSTREAM_WAREHOUSE, dir_->Str()},
        {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // Build the view column mixing references to b0 (field_id=1) and b1 (field_id=2).
    // - row 0: b0 row 0 -> "b0_data_0"
    // - row 1: b1 row 1 -> "b1_data_1"
    // - row 2: b0 row 2 -> "b0_data_2"
    // - row 3: b1 row 3 -> "b1_data_3"
    Identifier upstream_identifier(upstream_db_name, upstream_table_name);
    auto append_view = [&](int32_t field_id, int64_t row_id, arrow::LargeBinaryBuilder* builder) {
        BlobViewStruct view_struct(upstream_identifier, field_id, row_id);
        auto serialized = view_struct.Serialize(pool_);
        ASSERT_TRUE(
            builder
                ->Append(reinterpret_cast<const uint8_t*>(serialized->data()), serialized->size())
                .ok());
    };
    arrow::LargeBinaryBuilder view_builder;
    append_view(/*field_id=*/1, /*row_id=*/0, &view_builder);
    append_view(/*field_id=*/2, /*row_id=*/1, &view_builder);
    append_view(/*field_id=*/1, /*row_id=*/2, &view_builder);
    append_view(/*field_id=*/2, /*row_id=*/3, &view_builder);
    std::shared_ptr<arrow::Array> write_view_array;
    ASSERT_TRUE(view_builder.Finish(&write_view_array).ok());

    auto write_f0_array =
        arrow::ipc::internal::json::ArrayFromJSON(arrow::int32(), R"([100,101,102,103])")
            .ValueOrDie();
    auto write_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::StructArray::Make(arrow::ArrayVector({write_f0_array, write_view_array}),
                                 std::vector<std::string>({"f0", "view"}))
            .ValueOrDie());

    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {write_struct}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // Scan/read the downstream table and verify the resolved view blobs.
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    ASSERT_OK_AND_ASSIGN(auto result,
                         ReadTable(table_path, schema->field_names(), plan, /*predicate=*/nullptr));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_EQ(read_struct->length(), 4);
    ASSERT_OK_AND_ASSIGN(auto result_array, ConvertDescriptorToRawBlob(read_struct, {"view"}));

    std::string expected_json = R"([
[100, "b0_data_0"],
[101, "b1_data_1"],
[102, "b0_data_2"],
[103, "b1_data_3"]
])";
    auto expected_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_struct));
    ASSERT_TRUE(result_array->Equals(expected_with_rk))
        << "result_array:" << result_array->ToString() << std::endl
        << "expected:" << expected_with_rk->ToString();
}

TEST_P(BlobTableInteTest, TestBlobViewFieldWithMultipleUpstreamTables) {
    auto file_format = GetParam();
    if (file_format == "avro") {
        return;
    }

    // Upstream table 1: append_table_with_multi_blob, with two blob fields f5 (field_id=5) and
    // f6 (field_id=6).
    const std::string multi_blob_db_name = "append_table_with_multi_blob";
    const std::string multi_blob_table_name = "append_table_with_multi_blob";
    {
        std::string src_db_path = paimon::test::GetDataDir() + file_format + "/" +
                                  multi_blob_db_name + ".db/" + multi_blob_table_name;
        std::string dst_db_path =
            PathUtil::JoinPath(dir_->Str(), multi_blob_db_name + ".db/" + multi_blob_table_name);
        ASSERT_TRUE(TestUtil::CopyDirectory(src_db_path, dst_db_path));
    }

    // Upstream table 2: blob_append_table_alter_table_with_cast_with_data_evolution, with one blob
    // field blob (field_id=8).
    const std::string alter_db_name = "blob_append_table_alter_table_with_cast_with_data_evolution";
    const std::string alter_table_name =
        "blob_append_table_alter_table_with_cast_with_data_evolution";
    {
        std::string src_db_path = paimon::test::GetDataDir() + file_format + "/" + alter_db_name +
                                  ".db/" + alter_table_name;
        std::string dst_db_path =
            PathUtil::JoinPath(dir_->Str(), alter_db_name + ".db/" + alter_table_name);
        ASSERT_TRUE(TestUtil::CopyDirectory(src_db_path, dst_db_path));
    }

    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("view1", true),
                                 BlobUtils::ToArrowField("view2", true)};
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, file_format},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_VIEW_FIELD, "view1,view2"},
        {Options::BLOB_VIEW_UPSTREAM_WAREHOUSE, dir_->Str()},
        {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    Identifier multi_blob_identifier(multi_blob_db_name, multi_blob_table_name);
    Identifier alter_identifier(alter_db_name, alter_table_name);

    auto append_view = [&](const Identifier& identifier, int32_t field_id, int64_t row_id,
                           arrow::LargeBinaryBuilder* builder) {
        BlobViewStruct view_struct(identifier, field_id, row_id);
        auto serialized = view_struct.Serialize(pool_);
        ASSERT_TRUE(
            builder
                ->Append(reinterpret_cast<const uint8_t*>(serialized->data()), serialized->size())
                .ok());
    };

    // Build view1 column. References multi_blob.f5 (field_id=5) and f6 (field_id=6).
    // Some upstream cells are referenced more than once on purpose.
    // - row 0: f5 row 3  -> 'D' * 1024
    // - row 1: f6 row 1  -> 'b' * 2048
    // - row 2: f5 row 3  -> 'D' * 1024  (repeat of row 0)
    // - row 3: f6 row 4  -> 'e' * 2048
    // - row 4: f5 row 3  -> 'D' * 1024  (repeat of row 0)
    // - row 5: f6 row 1  -> 'b' * 2048  (repeat of row 1)
    // - row 6: f5 row 5  -> 'F' * 1024
    // - row 7: f6 row 6  -> 'g' * 2048
    arrow::LargeBinaryBuilder view1_builder;
    append_view(multi_blob_identifier, /*field_id=*/5, /*row_id=*/3, &view1_builder);
    append_view(multi_blob_identifier, /*field_id=*/6, /*row_id=*/1, &view1_builder);
    append_view(multi_blob_identifier, /*field_id=*/5, /*row_id=*/3, &view1_builder);
    append_view(multi_blob_identifier, /*field_id=*/6, /*row_id=*/4, &view1_builder);
    append_view(multi_blob_identifier, /*field_id=*/5, /*row_id=*/3, &view1_builder);
    append_view(multi_blob_identifier, /*field_id=*/6, /*row_id=*/1, &view1_builder);
    append_view(multi_blob_identifier, /*field_id=*/5, /*row_id=*/5, &view1_builder);
    append_view(multi_blob_identifier, /*field_id=*/6, /*row_id=*/6, &view1_builder);
    std::shared_ptr<arrow::Array> write_view1_array;
    ASSERT_TRUE(view1_builder.Finish(&write_view1_array).ok());

    // Build view2 column. References alter.blob (field_id=8).
    // Some upstream cells are referenced more than once on purpose.
    // - row 0: blob row 0  -> "Lily"
    // - row 1: blob row 5  -> "Apple"
    // - row 2: blob row 0  -> "Lily"        (repeat of row 0)
    // - row 3: blob row 2  -> "Bob"
    // - row 4: blob row 5  -> "Apple"       (repeat of row 1)
    // - row 5: blob row 9  -> "Elderberry"
    // - row 6: blob row 0  -> "Lily"        (repeat of row 0)
    // - row 7: blob row 3  -> "Cindy"
    arrow::LargeBinaryBuilder view2_builder;
    append_view(alter_identifier, /*field_id=*/8, /*row_id=*/0, &view2_builder);
    append_view(alter_identifier, /*field_id=*/8, /*row_id=*/5, &view2_builder);
    append_view(alter_identifier, /*field_id=*/8, /*row_id=*/0, &view2_builder);
    append_view(alter_identifier, /*field_id=*/8, /*row_id=*/2, &view2_builder);
    append_view(alter_identifier, /*field_id=*/8, /*row_id=*/5, &view2_builder);
    append_view(alter_identifier, /*field_id=*/8, /*row_id=*/9, &view2_builder);
    append_view(alter_identifier, /*field_id=*/8, /*row_id=*/0, &view2_builder);
    append_view(alter_identifier, /*field_id=*/8, /*row_id=*/3, &view2_builder);
    std::shared_ptr<arrow::Array> write_view2_array;
    ASSERT_TRUE(view2_builder.Finish(&write_view2_array).ok());

    auto write_f0_array = arrow::ipc::internal::json::ArrayFromJSON(
                              arrow::int32(), R"([100,101,102,103,104,105,106,107])")
                              .ValueOrDie();
    auto write_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::StructArray::Make(
            arrow::ArrayVector({write_f0_array, write_view1_array, write_view2_array}),
            std::vector<std::string>({"f0", "view1", "view2"}))
            .ValueOrDie());

    // write & commit
    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {write_struct}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // Expected blob contents per referenced upstream cell.
    std::string blob_f5_row3(1024, 'D');  // multi_blob.f5 row 3
    std::string blob_f5_row5(1024, 'F');  // multi_blob.f5 row 5
    std::string blob_f6_row1(2048, 'b');  // multi_blob.f6 row 1
    std::string blob_f6_row4(2048, 'e');  // multi_blob.f6 row 4
    std::string blob_f6_row6(2048, 'g');  // multi_blob.f6 row 6

    std::vector<std::string> read_fields = {"view2", "view1", "f0"};
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    ASSERT_OK_AND_ASSIGN(auto result,
                         ReadTable(table_path, read_fields, plan, /*predicate=*/nullptr));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_EQ(read_struct->length(), 8);
    ASSERT_OK_AND_ASSIGN(auto result_array,
                         ConvertDescriptorToRawBlob(read_struct, {"view1", "view2"}));

    // Expected struct follows the requested (shuffled) column order: view2, view1, f0.
    arrow::FieldVector expected_fields = {BlobUtils::ToArrowField("view2", true),
                                          BlobUtils::ToArrowField("view1", true),
                                          arrow::field("f0", arrow::int32())};
    // clang-format off
    std::string expected_json = R"([
["Lily", ")" + blob_f5_row3 + R"(", 100],
["Apple", ")" + blob_f6_row1 + R"(", 101],
["Lily", ")" + blob_f5_row3 + R"(", 102],
["Bob", ")" + blob_f6_row4 + R"(", 103],
["Apple", ")" + blob_f5_row3 + R"(", 104],
["Elderberry", ")" + blob_f6_row1 + R"(", 105],
["Lily", ")" + blob_f5_row5 + R"(", 106],
["Cindy", ")" + blob_f6_row6 + R"(", 107]
])";
    // clang-format on
    auto expected_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(expected_fields), expected_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_struct));

    ASSERT_TRUE(result_array->Equals(expected_with_rk))
        << "result_array:" << result_array->ToString() << std::endl
        << "expected:" << expected_with_rk->ToString();
}

TEST_P(BlobTableInteTest, TestBlobViewFailsWhenBothPathsAbsent) {
    auto file_format = GetParam();
    auto upstream_dir = UniqueTestDirectory::Create("local");
    const std::string upstream_db_name = "nonexistent_db";
    const std::string upstream_table_name = "nonexistent_table";

    // Build downstream table that references the non-existent upstream table.
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("view", true)};
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, file_format},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_VIEW_FIELD, "view"},
        {Options::BLOB_VIEW_UPSTREAM_WAREHOUSE, upstream_dir->Str()},
        {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // Write a single row with a BlobViewStruct pointing to the non-existent upstream table.
    Identifier upstream_identifier(upstream_db_name, upstream_table_name);
    BlobViewStruct view_struct(upstream_identifier, /*field_id=*/2, /*row_id=*/0);
    auto serialized = view_struct.Serialize(pool_);
    arrow::LargeBinaryBuilder view_builder;
    ASSERT_TRUE(
        view_builder
            .Append(reinterpret_cast<const uint8_t*>(serialized->data()), serialized->size())
            .ok());
    std::shared_ptr<arrow::Array> write_view_array;
    ASSERT_TRUE(view_builder.Finish(&write_view_array).ok());
    auto write_f0_array =
        arrow::ipc::internal::json::ArrayFromJSON(arrow::int32(), R"([100])").ValueOrDie();
    auto write_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::StructArray::Make(arrow::ArrayVector({write_f0_array, write_view_array}),
                                 std::vector<std::string>({"f0", "view"}))
            .ValueOrDie());

    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {write_struct}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // Reading should fail because both paths are absent.
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    ASSERT_NOK_WITH_MSG(ReadTable(table_path, schema->field_names(), plan, /*predicate=*/nullptr),
                        "Ambiguous table path");
}

TEST_P(BlobTableInteTest, TestBlobViewWithFallbackPath) {
    auto file_format = GetParam();
    const std::string upstream_db_name = "fallback_db";
    const std::string upstream_table_name = "fallback_table";
    arrow::FieldVector upstream_fields = {arrow::field("f0", arrow::int32()),
                                          BlobUtils::ToArrowField("blob", true)};
    auto upstream_schema = arrow::schema(upstream_fields);
    std::map<std::string, std::string> upstream_options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, file_format},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_AS_DESCRIPTOR, "true"},
        {Options::FILE_SYSTEM, "local"}};

    // Create the upstream table at the fallback path: <warehouse>/db/table (no .db).
    auto upstream_dir = UniqueTestDirectory::Create("local");
    std::string fallback_table_path =
        PathUtil::JoinPath(upstream_dir->Str(), upstream_db_name + "/" + upstream_table_name);

    // Manually create schema at fallback path so it can be read as a valid paimon table.
    {
        // Use a temporary warehouse with Catalog to build the table data, then copy to fallback.
        auto temp_dir = UniqueTestDirectory::Create("local");
        ::ArrowSchema c_schema;
        ASSERT_TRUE(arrow::ExportSchema(*upstream_schema, &c_schema).ok());
        ASSERT_OK_AND_ASSIGN(auto catalog,
                             Catalog::Create(temp_dir->Str(), {{Options::FILE_SYSTEM, "local"}}));
        ASSERT_OK(catalog->CreateDatabase(upstream_db_name, {}, /*ignore_if_exists=*/true));
        ASSERT_OK(catalog->CreateTable(Identifier(upstream_db_name, upstream_table_name), &c_schema,
                                       /*partition_keys=*/{}, /*primary_keys=*/{}, upstream_options,
                                       /*ignore_if_exists=*/false));
        std::string temp_table_path =
            PathUtil::JoinPath(temp_dir->Str(), upstream_db_name + ".db/" + upstream_table_name);

        // Write data to the temp table.
        std::string raw_json = R"([[0, "hello"], [1, "world"]])";
        auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
            arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(upstream_fields), raw_json)
                .ValueOrDie());
        ASSERT_OK_AND_ASSIGN(auto desc_array, ConvertRawBlobToDescriptor(raw_array, {"blob"}));
        ASSERT_OK_AND_ASSIGN(
            auto upstream_commit_msgs,
            WriteArray(temp_table_path, {}, upstream_schema->field_names(), {desc_array}));
        ASSERT_OK(Commit(temp_table_path, upstream_commit_msgs));

        // Copy the temp table to the fallback path (without .db).
        ASSERT_TRUE(TestUtil::CopyDirectory(temp_table_path, fallback_table_path));
    }

    // Build the downstream table.
    arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                 BlobUtils::ToArrowField("view", true)};
    std::map<std::string, std::string> options = {
        {Options::MANIFEST_FORMAT, "orc"},
        {Options::FILE_FORMAT, file_format},
        {Options::BUCKET, "-1"},
        {Options::ROW_TRACKING_ENABLED, "true"},
        {Options::DATA_EVOLUTION_ENABLED, "true"},
        {Options::BLOB_VIEW_FIELD, "view"},
        {Options::BLOB_VIEW_UPSTREAM_WAREHOUSE, upstream_dir->Str()},
        {Options::FILE_SYSTEM, "local"}};
    CreateTable(fields, /*partition_keys=*/{}, options);
    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");

    // Write downstream rows referencing the upstream fallback table.
    Identifier upstream_identifier(upstream_db_name, upstream_table_name);
    arrow::LargeBinaryBuilder view_builder;
    for (int64_t row = 0; row < 2; ++row) {
        BlobViewStruct view_struct(upstream_identifier, /*field_id=*/1, /*row_id=*/row);
        auto serialized = view_struct.Serialize(pool_);
        ASSERT_TRUE(
            view_builder
                .Append(reinterpret_cast<const uint8_t*>(serialized->data()), serialized->size())
                .ok());
    }
    std::shared_ptr<arrow::Array> write_view_array;
    ASSERT_TRUE(view_builder.Finish(&write_view_array).ok());
    auto write_f0_array =
        arrow::ipc::internal::json::ArrayFromJSON(arrow::int32(), R"([100, 101])").ValueOrDie();
    auto write_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::StructArray::Make(arrow::ArrayVector({write_f0_array, write_view_array}),
                                 std::vector<std::string>({"f0", "view"}))
            .ValueOrDie());

    auto schema = arrow::schema(fields);
    ASSERT_OK_AND_ASSIGN(auto commit_msgs,
                         WriteArray(table_path, {}, schema->field_names(), {write_struct}));
    ASSERT_OK(Commit(table_path, commit_msgs));

    // Read and verify
    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    ASSERT_OK_AND_ASSIGN(auto result,
                         ReadTable(table_path, schema->field_names(), plan, /*predicate=*/nullptr));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
    ASSERT_EQ(read_struct->length(), 2);
    ASSERT_OK_AND_ASSIGN(auto result_array, ConvertDescriptorToRawBlob(read_struct, {"view"}));

    std::string expected_json = R"([[100, "hello"], [101, "world"]])";
    auto expected_struct = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_json)
            .ValueOrDie());
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_struct));
    ASSERT_TRUE(result_array->Equals(expected_with_rk))
        << "result_array:" << result_array->ToString() << std::endl
        << "expected:" << expected_with_rk->ToString();
}

TEST_P(BlobTableInteTest, TestReadBlobDescriptorFieldFromJava) {
    auto file_format = GetParam();
    if (file_format == "avro") {
        return;
    }
    std::string table_path =
        GetDataDir() + "/" + file_format + "/blob_desc_field.db/blob_desc_field";
    arrow::FieldVector fields = {
        arrow::field("f0", arrow::int32()), BlobUtils::ToArrowField("b0", true),
        BlobUtils::ToArrowField("b1", true), BlobUtils::ToArrowField("b2", true),
        BlobUtils::ToArrowField("b3", true)};
    auto schema = arrow::schema(fields);
    // b0: all non-null, b1: has nulls, b2: all non-null, b3: has nulls
    std::string raw_json = R"([
        [1, "img_0", null,    "raw_2_0", "raw_3_0"],
        [2, "img_1", "vid_1", "raw_2_1", null      ],
        [3, "img_2", null,    "raw_2_2", "raw_3_2" ]
    ])";
    auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());

    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
    std::map<std::string, std::string> read_options = {{Options::BLOB_AS_DESCRIPTOR, "false"}};
    ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, schema->field_names(), plan,
                                                /*predicate=*/nullptr, read_options));
    ASSERT_TRUE(result.chunked_array);
    auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
    auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);

    // After read, b0 and b1 are both descriptor-stored; resolve all back to raw bytes.
    // Java-generated descriptors may contain absolute paths from the generation machine.
    // Rewrite them to the portable blob directories inside the copied table path.
    BlobDescriptorPathRewrite rewrite{table_path, {"raw_blob", "external_blob"}};
    ASSERT_OK_AND_ASSIGN(auto resolved,
                         ConvertDescriptorToRawBlob(read_struct, {"b0", "b1"}, rewrite));
    ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(raw_array));
    ASSERT_TRUE(resolved->Equals(expected_with_rk));
}

}  // namespace paimon::test
